diff --git a/.gitattributes b/.gitattributes index 3c7203c6..9e007d9d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,5 +15,11 @@ # Full upstream rule artifacts retain their exact pinned bytes on every platform. /config/provider_rule_sources/*.yml -text whitespace=-blank-at-eol +# Selected SACL review plans pin these exact source bytes. +/config/audit_sacl_targets.json text eol=lf +/scripts/TargetedSaclPlanning.ps1 text eol=lf +/scripts/SelectedSaclConfiguration.ps1 text eol=lf +/scripts/SelectedSaclNative.cs text eol=lf +/modules/AuditCatalog.psm1 text eol=lf # Fixed public pending-request fixture is pinned by its exact byte hash. /tests/fixtures/adcs-pending-probe.csr text eol=lf diff --git a/.github/workflows/selected-sacl.yml b/.github/workflows/selected-sacl.yml new file mode 100644 index 00000000..ee394630 --- /dev/null +++ b/.github/workflows/selected-sacl.yml @@ -0,0 +1,36 @@ +name: Selected native SACL configuration +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + selected-sacl: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Plan, selection, races and receipts on Windows PowerShell 5.1 + shell: powershell + run: ./tests/SelectedSacl.Tests.ps1 + - name: Existing companion planning regressions on Windows PowerShell 5.1 + shell: powershell + run: ./tests/TargetedSaclPlanning.Tests.ps1 + - name: Explicit disposable native targets and4657/4663 on Windows PowerShell 5.1 + shell: powershell + run: ./tests/SelectedSacl.Windows.Tests.ps1 -AllowDisposableSaclWrite + - name: Plan, selection, races and receipts on PowerShell 7 + shell: pwsh + run: ./tests/SelectedSacl.Tests.ps1 + - name: Existing companion planning regressions on PowerShell 7 + shell: pwsh + run: ./tests/TargetedSaclPlanning.Tests.ps1 + - name: Explicit disposable native targets and4657/4663 on PowerShell 7 + shell: pwsh + run: ./tests/SelectedSacl.Windows.Tests.ps1 -AllowDisposableSaclWrite diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index b69b5429..f15e73c1 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 既存のローカルファイル・レジストリを明示的に選択して監査・計画・設定する`targeted-sacl`を追加しました。出典ごとの監査ACE、実効ポリシーの前提条件、継承の個別同意を確認し、対象ハンドルを使ってSACLだけを更新します。既存のセキュリティ記述子を保持し、変更前と検証済みの記録、最終状態の確認、特権の復元に対応します。使い捨てオブジェクトのネイティブテストを追加し、子孫全体・転送・Sigmaの利用可能性は別途検証が必要です。 (#422) (@Shirofune-Security) - 既存CA向けにネイティブの`adcs-auditing`監査・計画・出典付き設定を追加しました。CAと証明書の識別、監査の前提条件、型付き復旧記録、変更直前と読戻しの検証を共有し、従来のCA設定も同じ処理を使用します。停止中のCAは起動せず、専用コマンドでのフィルター変更には再起動の明示指定を求めます。設定一致・再起動の観測・イベント証拠を区別し、Sigma利用可能数には加算しません。使い捨てのスタンドアロンCAテストで保留要求の4886/4889 XMLを関連付け、元の監査設定と作成した資源を復元・削除します。エンタープライズCA・DC・収集基盤の検証は別途必要です。 (#421) (@Shirofune-Security) - `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68bc85f5..64e03e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added opt-in `targeted-sacl` auditing, reviewed plans and selective configuration for existing local file/registry targets. Source-specific audit ACEs, policy prerequisites, inheritance consent, handle-bound SACL-only writes, preserved security descriptors, pending/confirmed receipts and final checks keep target scope explicit. Privileges are restored; native disposable-object tests cover file/registry events without claiming descendant, forwarding or Sigma readiness. (#422) (@Shirofune-Security) - Added dedicated native `adcs-auditing` audit, plan and source-profile configuration, with pinned CA/certificate identity, verified audit prerequisites, typed journals and fresh/readback guards. Legacy CA configuration uses the same engine; stopped CAs are preserved and dedicated filter changes require explicit restart consent. Policy matches, observed restarts and request events remain separate, with no Sigma credit. Disposable standalone-CA tests collect correlated pending-request 4886/4889 XML and restore policy/created resources; enterprise/DC/backend acceptance remains separate. (#421) (@Shirofune-Security) - Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 33a47672..a43170a4 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -77,6 +77,11 @@ [ValidateSet('Plan','Run')][string]$ProbeAction = 'Plan', [string]$ProbeOutputPath, [ValidateRange(1,30)][int]$ProbeTimeoutSeconds = 15, + [ValidateSet('Audit','Plan','Configure')][string]$TargetSaclAction = 'Audit', + [string]$TargetSaclProfile, + [string[]]$TargetSaclId, + [string]$TargetSaclPlanPath, + [switch]$TargetSaclIncludeChildren, [ValidateSet('Audit','Plan','Configure')][string]$AdcsAction = 'Audit', [string]$AdcsProfile, [switch]$AllowRestart, @@ -133,6 +138,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1") . (Join-Path $ScriptRoot "scripts/AuditScoring.ps1") . (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") +. (Join-Path $ScriptRoot "scripts/SelectedSaclConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/GpoAuditPackages.ps1") . (Join-Path $ScriptRoot "scripts/IntuneAuditExport.ps1") . (Join-Path $ScriptRoot "scripts/EvtxRecovery.ps1") @@ -1831,6 +1837,7 @@ function Get-WelaUserProfiles { $usage = @" Usage: + ./WELA.ps1 targeted-sacl -Help # Selected existing local SACL targets; read-only by default ./WELA.ps1 gpo-package -GpoAction Plan -GpoProfile wela-2.2.0 -Role Client -Build 26100 ./WELA.ps1 gpo-package -GpoAction Export -GpoProfile wela-2.2.0 -Role Client -Build 26100 -GpoOutputPath .\audit-components ./WELA.ps1 gpo-package -GpoAction Verify -GpoOutputPath .\audit-components @@ -1911,6 +1918,12 @@ Write-Host "" Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" +if ($Cmd -ne 'targeted-sacl' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'TargetSacl*' }).Count) { + throw 'TargetSacl options require targeted-sacl. No command was run.' +} +if ($Cmd -eq 'targeted-sacl' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','TargetSaclAction','TargetSaclProfile','TargetSaclId','TargetSaclPlanPath','TargetSaclIncludeChildren','IncludeOptional','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) { + throw 'targeted-sacl accepts only selected-target, consent and report options. No command was run.' +} if ($Cmd -ne 'adcs-auditing' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('AdcsAction','AdcsProfile','AllowRestart') }).Count) { throw 'AD CS options require adcs-auditing. No command was run.' } @@ -2015,7 +2028,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 '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 '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 'smb-auditing' -and $SmbAction -eq 'Configure') -and @@ -2024,7 +2037,7 @@ if ($DryRun -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Resto -not ($Cmd -in @('wef-source','wec-collector') -and $WefAction -eq 'Configure') -and -not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback')) -and -not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, powershell-transcription -TranscriptionAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, ad-object-sacl -AdSaclAction Configure|Rollback, ldap-diagnostics -LdapAction Configure, provider-packs -ProviderAction Configure, audit-integrity -IntegrityAction Configure, and audit-notifications -NotificationAction Configure; gpo-package -GpoAction Export writes component files only. No command was run." + throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, powershell-transcription -TranscriptionAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, ad-object-sacl -AdSaclAction Configure|Rollback, ldap-diagnostics -LdapAction Configure, provider-packs -ProviderAction Configure, audit-integrity -IntegrityAction Configure, audit-notifications -NotificationAction Configure, and targeted-sacl -TargetSaclAction Configure; gpo-package -GpoAction Export writes component files only. No command was run." } if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') { throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.' @@ -2054,6 +2067,12 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi } switch ($Cmd.ToLower()) { + 'targeted-sacl' { + if ($Help) { Write-Host 'Usage: ./WELA.ps1 targeted-sacl -TargetSaclProfile profile-id [-TargetSaclId id,...] [-TargetSaclAction Audit|Plan] [-IncludeOptional] [-TargetSaclIncludeChildren] [-ResultsPath new-plan.json]. Configure requires -TargetSaclAction Configure -TargetSaclPlanPath reviewed.json -TargetSaclId same-ids [-TargetSaclIncludeChildren] [-IncludeOptional] [-DryRun] [-Auto] [-BackupPath new-directory] [-ResultsPath new-results.json]. Existing local targets only; see docs/selected-sacl-configuration.md.'; return } + $report=Invoke-WelaSelectedSacl -Action $TargetSaclAction -Profile $TargetSaclProfile -Ids $TargetSaclId -PlanPath $TargetSaclPlanPath -IncludeOptional:$IncludeOptional -IncludeChildren:$TargetSaclIncludeChildren -DryRun:$DryRun -Auto:$Auto -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if ($report.ExitCode) { exit $report.ExitCode } + } 'adcs-auditing' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 adcs-auditing [-AdcsAction Audit|Plan|Configure] [-AdcsProfile microsoft-identity-ca-2026-09] [-AllowRestart] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath new.json]. Audit is read-only; Plan/Configure requires a source. Filter changes require AllowRestart. Existing stopped CAs are never started. See docs/adcs-auditing.md.'; return } $report=Invoke-WelaAdcsCommand -Action $AdcsAction -Profile $AdcsProfile -AllowRestart:$AllowRestart -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath diff --git a/docs/selected-sacl-configuration.md b/docs/selected-sacl-configuration.md new file mode 100644 index 00000000..a44843db --- /dev/null +++ b/docs/selected-sacl-configuration.md @@ -0,0 +1,72 @@ +# Selected local file and registry audit SACLs + +`targeted-sacl` is a dedicated, opt-in workflow for existing local objects from a built-in profile's shared targeted-SACL companion plan. Its default action is read-only `Audit`. It adds only a selected audit ACE; it never enables object-audit policy, creates missing targets, loads user hives, changes DACLs/owners, clears logs or configures global object auditing. Sysmon is excluded. The existing broader `configure-sacl` command remains separate. + +## Discover, review, then configure + +Use native 64-bit Windows PowerShell 5.1 or PowerShell 7. Reading selected SACLs requires an assigned `SeSecurityPrivilege`; an elevated administrator normally has it. The command enables that privilege only around a native target operation and restores its previous state on success or failure. Impersonated callers are refused. Unknown privileges, descriptor reads or role/build/patch context block configuration. + +```powershell +# Inventory resolves the shared definitions and loaded-user known folders, +# but does not read any object SACL until an explicit target ID is selected. +$inventory = ./WELA.ps1 targeted-sacl -TargetSaclProfile wela-2.2.0 -IncludeOptional +$inventory.Catalog | Select-Object Id, @{n='Path';e={$_.Definition.Path}}, + @{n='Origin';e={$_.Definition.Origin}}, @{n='Principal';e={$_.Definition.PrincipalSid}} + +# Select exactly one reviewed WELA Run target, distinct from the WEF entry. +$target = @($inventory.Catalog | Where-Object { + $_.Definition.Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' -and + $_.Definition.PrincipalSid -eq 'S-1-1-0' +}) +if ($target.Count -ne 1) { throw 'Review an unambiguous target ID.' } +$targetId = $target[0].Id + +./WELA.ps1 targeted-sacl -TargetSaclAction Plan -TargetSaclProfile wela-2.2.0 ` + -IncludeOptional -TargetSaclId $targetId -TargetSaclIncludeChildren ` + -ResultsPath .\selected-sacl-plan.json + +# Inspect the complete plan, including Status, Diagnostic, Before and Ace. +./WELA.ps1 targeted-sacl -TargetSaclAction Configure ` + -TargetSaclPlanPath .\selected-sacl-plan.json -TargetSaclId $targetId ` + -IncludeOptional -TargetSaclIncludeChildren -DryRun + +# Run only after reviewing the actual target and inheritance effects. +./WELA.ps1 targeted-sacl -TargetSaclAction Configure ` + -TargetSaclPlanPath .\selected-sacl-plan.json -TargetSaclId $targetId ` + -IncludeOptional -TargetSaclIncludeChildren -BackupPath C:\WELA-Recovery\selected-001 ` + -ResultsPath .\selected-sacl-results.json +``` + +The backup parent must already exist; use an operator-controlled recovery location. The final backup directory and result files must be new. Paths resolve relative to PowerShell's current location. `-Auto` accepts per-target confirmation only; it does not bypass selection, source/context, policy, inheritance or descriptor checks. `-DryRun` is supported only for Configure and creates no recovery files or target changes. The same exact target IDs, optional selection and inheritance consent must be supplied when consuming the plan. These dedicated options are rejected on unrelated commands, including legacy `configure-sacl`. + +An Audit without target IDs returns the catalog and user inventory. Audit/Plan with IDs reads only selected target descriptors. Plan requires nonempty selection; Configure requires the saved plan and matching IDs. A changed catalog, generator, source profile, target identity/security descriptor, or actual host context requires a fresh review. Plan files use bounded strict JSON and trusted definitions are regenerated; editing a path, principal, mask or source identity cannot supply arbitrary native write instructions. This initial command uses built-in profiles, not `-ProfileFile` or external target catalogs. + +## Exact policy and target boundaries + +WELA companion targets retain Everyone, Success+Failure and their declared rights. Microsoft WEF Appendix B Run/RunOnce entries retain Authenticated Users, Success and their distinct SetValue/CreateSubKey/Delete masks. They have distinct target IDs and are never silently merged into one recommendation. Inclusion in the companion catalog does not mean every Microsoft/CIS/ASD profile requires every WELA path. See [the shared companion plan](targeted-sacl-planning.md) for source distinctions. + +The required File System or Registry success/failure audit bits and typed `SCENoApplyLegacyAuditPolicy=1` must **already** be observed. Unselected optional or not-applicable policies and explicit No Auditing block configuration. An unchanged/Not Configured source can use separately established effective auditing, but this workflow does not enable it. Configure an appropriate policy separately through its authority and generate a fresh plan afterward. Handle Manipulation events and other prerequisites are separate; this command does not infer that all event families will fire. + +Unloaded hives, missing files/keys, remote or mapped-network paths, reparse points, unknown user folders and unresolved catalog entries remain blocked. No offline hive is mounted, and no sensitive file or autostart key is created. Loaded-user paths use that user's known-folder metadata; another user's AppData is never replaced with the operator's environment. Directory and registry inheritance requires explicit `-TargetSaclIncludeChildren`: Windows can propagate inheritable SACL ACEs to **existing** descendants. Review those descendants separately; the report verifies the selected object, not complete descendant coverage. + +## Native preservation, receipts and recovery + +Native reads and writes use a handle to the selected object. File handles verify the final local path and file identity; registry components are opened without following symbolic links. Registry identity includes the observed last-write time, so unrelated edits can conservatively require a new plan. The writer rereads the handle immediately before `SetSecurityInfo` with **SACL_SECURITY_INFORMATION only**, passing no owner, group or DACL changes. The original SACL entries are retained as binary ACEs and the requested ordinary audit ACE is appended. Unknown or inherited entries are preserved without treating them as proof of the requested explicit ACE. An existing explicit ordinary ACE with matching flags/SID and all required rights is already compliant. + +Each attempted change first creates `.pending.json`, containing the original descriptor bytes for the recorded observation scope, ACEs, target identity, source hashes and proposed audit entry. Only successful native write, preserved-state checks and matching readback create the separate `.confirmed.json`. A failed write, unreadable after-state or privilege-restoration failure leaves pending evidence and returns failure, without a confirmed ownership claim. Final checks detect changes after an earlier successful write. Partial failures stay visible; an applied earlier target is not silently rolled back. A confirmed receipt records its verified moment and must still be compared with the final result and current state. + +For recovery, review the receipts and a fresh descriptor first. Remove only the explicit ACE demonstrated to have been added by this run; do not remove a matching ACE that was already present. Preserve the existing owner, group, DACL, protection flags and all newer audit entries. If Windows propagated inheritance, inspect descendants separately. No automatic full-descriptor replacement or bulk rollback is provided by this command. Pending receipts cannot establish that an ACE belongs to WELA; retain them for manual investigation. + +Windows security updates are not a compare-and-swap transaction against other administrators or GPO. Fresh-state checks and handle-bound mutation reduce races but do not lock out concurrent SACL writers. Use an isolated change window; no later policy persistence or race-free inheritance guarantee is claimed. + +## Validation + +Mocked tests cover selection, source-specific masks, unsupported consent, source/plan/target races, denied reads, partial writes, non-SACL drift, pending/confirmed receipts, idempotence and public command guards. The Windows workflow explicitly permits mutations only on GitHub-hosted disposable Server 2022/2025 runners: it creates owned temporary file/registry targets, temporarily enables their two audit subcategories and precedence, adds audit ACEs through the real adapter, and searches for benign 4663/4657 events matching the exact targets. It restores all original audit masks and typed precedence and removes only owned targets. This fixture does not modify any catalog system target. + +Native CI results must be reviewed before claiming those test cases passed. Windows 11, DC/CA, user redirection, inheritance across populated trees, forwarding and actual Sigma/backend execution remain separate acceptance work. Every report remains `GenerationReadiness=Conditional` with `UsableRuleCredit=0`. + +Primary API references: [GetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getsecurityinfo), [SetSecurityInfo and inheritance](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo), [registry open/link behavior](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regopenkeyexw), [file handle and sharing flags](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew). + +Native reads request the explicit Windows SDK section union `0x1ff`: owner, group, DACL, audit, integrity label, resource attributes, central-access policy, process trust label and access filter. Snapshots record `SecurityInformation=511` and `DescriptorScope`; future sections are explicitly unobserved. Server 2022/2025 file-handle diagnostics returned access denied for the aggregate BACKUP flag but success for this complete current section union. The adapter does not acquire broader privileges or drop unreadable sections; a failed union read blocks the target. Writes still request only `SACL_SECURITY_INFORMATION`. Existing inheritable ACEs, including unknown ACE types, also require child consent on containers because the native setter can propagate them. Two source entries that resolve to the same physical target are blocked before any write: configure one, then review a fresh plan for the other. See Microsoft’s [security information flags](https://learn.microsoft.com/en-us/windows/win32/secauthz/security-information). + +Section constants are defined by Microsoft’s [Windows SDK winnt.h](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/winnt.h). Future descriptor sections require a reviewed update to this bounded reader before they can be verified. diff --git a/docs/targeted-sacl-planning.md b/docs/targeted-sacl-planning.md index 240a224c..e6cba3e6 100644 --- a/docs/targeted-sacl-planning.md +++ b/docs/targeted-sacl-planning.md @@ -30,3 +30,5 @@ Configured user-file paths beneath `AppData\Roaming` retain their complete relat ## Isolated Windows validation still required On a snapshot, save the plan and effective policy, apply an explicitly approved targeted SACL, perform a benign operation on a disposable registry key/file that inherits the selected rule, and match Security event XML (for example 4657/4663) to that object, subject and access mask. Check relevant 4656/4658 events separately if Handle Manipulation is needed. Verify forwarding at the collector where required. Repeat for loaded/unloaded users, redirected paths and relevant client/server roles. Preserve before/after ACLs and remove only disposable test objects. This PR's mocked tests and native read-only CI do not supply event-generation or forwarding evidence, so issue #373 remains open for that acceptance work. + +For explicitly selected existing local targets, see the separate [reviewed SACL configuration workflow](selected-sacl-configuration.md). Profile planning itself remains read-only, and the legacy `configure-sacl` command is unchanged. diff --git a/scripts/SelectedSaclConfiguration.ps1 b/scripts/SelectedSaclConfiguration.ps1 new file mode 100644 index 00000000..dbacec30 --- /dev/null +++ b/scripts/SelectedSaclConfiguration.ps1 @@ -0,0 +1,268 @@ +# Explicit selected, existing local targets. No audit-policy writes or hive loading. +function Get-WelaSelectedSaclHash { + param([string[]]$Values) + $encoding=New-Object Text.UTF8Encoding($false,$true) + $text=(@($Values | ForEach-Object {[Convert]::ToBase64String($encoding.GetBytes([string]$_))}) -join '|') + $sha=[Security.Cryptography.SHA256]::Create() + try {([BitConverter]::ToString($sha.ComputeHash($encoding.GetBytes($text)))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()} +} +function Get-WelaSelectedSaclSources { + foreach($path in @('config/audit_sacl_targets.json','config/audit_profiles.json','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','scripts/TargetedSaclPlanning.ps1','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs')) { + [pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot "../$path") -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + } +} +function Get-WelaSelectedSaclContext { + $role=Get-WelaHostContext; $detail=Get-WelaDefaultContext + if(-not (Test-WelaDefaultContextComplete $detail) -or $role.Build -ne $detail.Build){throw 'Complete consistent actual Windows context is required.'} + $matches=switch($role.Role){ + Client {$detail.ProductType -eq 1 -and $detail.DomainRole -in @(0,1)} + MemberServer {$detail.ProductType -eq 3 -and $detail.DomainRole -in @(2,3)} + DomainController {$detail.ProductType -eq 2 -and $detail.DomainRole -in @(4,5)} + ADCS {$detail.ProductType -eq 3 -and $detail.DomainRole -in @(2,3) -and $detail.InstalledRoles -contains 'ADCS-Cert-Authority'} + default {$false} + } + if(-not $matches){throw 'Role contradicts detailed host context.'} + [pscustomobject]@{Computer=[Environment]::MachineName;Role=$role.Role;Build=$role.Build;Detail=$detail;Key=(Get-WelaSelectedSaclHash @([Environment]::MachineName,$role.Role,(Get-WelaDefaultContextKey $detail)))} +} +function Get-WelaSelectedSaclDefinitionKey { + param($Row) + Get-WelaSelectedSaclHash @($Row.Origin,$Row.Scope,$Row.UserSid,$Row.Path,$Row.Kind,$Row.PrincipalSid,($Row.AuditFlags -join ','),($Row.Rights -join ','),$Row.Inheritance,$Row.Propagation,$Row.Policy,$Row.PolicyMode,[string]$Row.PolicySelected,[string]$Row.RequiredPolicyMask) +} +function Get-WelaSelectedSaclSnapshotKey { + param($Snapshot) + if($null -eq $Snapshot -or $Snapshot.IsDirectory -isnot [bool] -or ($Snapshot.ControlFlags -isnot [int] -and $Snapshot.ControlFlags -isnot [long])){throw 'Malformed reviewed native snapshot.'} + if(($Snapshot.SecurityInformation -isnot [int] -and $Snapshot.SecurityInformation -isnot [long]) -or $Snapshot.SecurityInformation -ne 511 -or $Snapshot.DescriptorScope -cne 'WinSDK-defined sections 0x1ff; future sections unobserved'){throw 'Incomplete or unknown native descriptor observation scope.'} + $fields=@([string]$Snapshot.SecurityInformation,$Snapshot.DescriptorScope,$Snapshot.Path,$Snapshot.Kind,$Snapshot.Identity,[string]$Snapshot.IsDirectory,$Snapshot.DescriptorBase64,$Snapshot.Owner,$Snapshot.Group,$Snapshot.DaclBase64,[string]$Snapshot.ControlFlags) + foreach($ace in $Snapshot.Aces){$fields+=@($ace.Binary,[string]$ace.Type,[string]$ace.Flags,[string]$ace.Mask,$ace.Sid,[string]$ace.Ordinary)} + Get-WelaSelectedSaclHash $fields +} +function Get-WelaSelectedSaclCatalog { + param([string]$Profile,[switch]$IncludeOptional,$Context) + $current=Get-WelaEffectiveAuditPolicy + $plan=Get-WelaAuditProfilePlan -Profile $Profile -Role $Context.Role -Build $Context.Build -Current $current -IncludeOptional:$IncludeOptional + if($plan.referenceOnly){throw 'Reference-only defaults cannot select SACL configuration.'} + # User inventory/known-folder resolution is shared, but unselected object ACLs are not read. + $companion=Get-WelaTargetedSaclPlan -AuditPlan $plan -Live -SkipTargetObservation + $seen=@{} + $rows=@(foreach($row in $companion.Targets){ + $key=Get-WelaSelectedSaclDefinitionKey $row; $id='sacl-'+$key.Substring(0,24) + if($seen.ContainsKey($id)){throw 'Duplicate selected SACL identity.'};$seen[$id]=$true + [pscustomobject]@{Id=$id;DefinitionKey=$key;Definition=$row} + }) + [pscustomobject]@{Profile=$plan;Rows=$rows;UserInventory=$companion.UserInventory} +} +function Initialize-WelaSelectedSaclNative { + if($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess){throw 'Selected SACLs require native 64-bit Windows PowerShell.'} + if(-not ('Wela.SelectedSacl.Target' -as [type])) {Add-Type -Path (Join-Path $PSScriptRoot 'SelectedSaclNative.cs') -ErrorAction Stop} +} +function Resolve-WelaSelectedSaclNativePath { + param($Definition) + if($Definition.Resolution -notin @('Resolved','Redirected')){throw "Target path is unresolved: $($Definition.Resolution). No hive is loaded."} + $observation=Get-WelaSaclTargetObservation -Path $Definition.Path -Kind $Definition.Kind -SkipSaclRead + if($observation.PathState -ne 'Exists'){throw "Selected existing local target is unavailable: $($observation.PathState). $($observation.Diagnostic)"} + if($Definition.Kind -eq 'FileSystem') { + if($Definition.Path -notmatch '^[A-Za-z]:\\'){throw 'Only absolute local filesystem targets are supported.'} + if($Definition.Path.Substring(2).Contains(':') -or $Definition.Path -match '[*?<>|]|[ .](\\|$)'){throw 'Ambiguous filesystem target path.'} + $full=[IO.Path]::GetFullPath($Definition.Path) + if($full -ine $Definition.Path){throw 'Filesystem target path must be canonical.'} + return $full + } + if($Definition.Kind -ne 'Registry'){throw 'Unsupported target kind.'} + $path=$Definition.Path -replace '^HKLM:\\','HKEY_LOCAL_MACHINE\' -replace '^Registry::','' + if($path -notmatch '^HKEY_(LOCAL_MACHINE|USERS)\\[^\\]+' -or $path -match '\\\\|(^|\\)\.\.?($|\\)|[*?%/\x00-\x1f]'){throw 'Only canonical existing HKLM/HKU keys may be selected.'} + return $path +} +function Get-WelaSelectedSaclSnapshot { + param($Definition) + $path=Resolve-WelaSelectedSaclNativePath $Definition + Initialize-WelaSelectedSaclNative + $privilege=New-Object Wela.SelectedSacl.Privilege; $target=$null + try {$target=New-Object Wela.SelectedSacl.Target($Definition.Kind,$path);$target.Read()} + finally {if($target){$target.Dispose()};$privilege.Dispose()} +} +function Get-WelaSelectedSaclAce { + param($Definition,$Snapshot,[switch]$IncludeChildren) + if($Definition.PrincipalSid -notin @('S-1-1-0','S-1-5-11') -or $Definition.Propagation -ne 'None'){throw 'Unsupported catalog principal or propagation.'} + $maps=if($Definition.Kind -eq 'Registry') {@{QueryValues=1;SetValue=2;CreateSubKey=4;EnumerateSubKeys=8;Notify=16;Delete=65536;ReadPermissions=131072;ChangePermissions=262144;TakeOwnership=524288;ReadKey=131097;WriteKey=131078}} else {@{ReadData=1;WriteData=2;AppendData=4;ReadExtendedAttributes=8;WriteExtendedAttributes=16;ExecuteFile=32;DeleteSubdirectoriesAndFiles=64;ReadAttributes=128;WriteAttributes=256;Delete=65536;ReadPermissions=131072;ChangePermissions=262144;TakeOwnership=524288;Read=131209;Write=278;ReadAndExecute=131241;Modify=197055;FullControl=2032127;ListDirectory=1;CreateFiles=2;CreateDirectories=4;Traverse=32}} + $mask=0;foreach($right in $Definition.Rights){if(-not $maps.ContainsKey($right)){throw "Unsupported catalog audit right: $right"};$mask=$mask -bor $maps[$right]} + if(-not $mask){throw 'No audit rights selected.'} + $flags=0;$policyMask=0 + foreach($flag in $Definition.AuditFlags){switch -Exact ($flag){Success {$flags=$flags -bor 64;$policyMask=$policyMask -bor 1} Failure {$flags=$flags -bor 128;$policyMask=$policyMask -bor 2} default {throw 'Unsupported audit outcome.'}}} + $inherit=$Definition.Inheritance -ne 'None' -and ($Definition.Kind -eq 'Registry' -or $Snapshot.IsDirectory) + $container=$Definition.Kind -eq 'Registry' -or $Snapshot.IsDirectory + $existingInheritance=$container -and @($Snapshot.Aces | Where-Object {($_.Flags -band 3) -ne 0}).Count -gt 0 + if(($inherit -or $existingInheritance) -and -not $IncludeChildren){throw 'Source or existing SACL inheritance requires explicit -TargetSaclIncludeChildren consent; existing descendants can receive audit ACEs.'} + if($inherit){$flags=$flags -bor $(if($Definition.Kind -eq 'Registry'){2}else{3})} + [pscustomobject]@{Sid=$Definition.PrincipalSid;Mask=$mask;Flags=$flags;RequiredPolicyMask=$policyMask} +} +function Test-WelaSelectedSaclAce { + param($Snapshot,$Ace) + return @($Snapshot.Aces | Where-Object {$_.Ordinary -eq $true -and $_.Type -eq 2 -and $_.Sid -ceq $Ace.Sid -and $_.Flags -eq $Ace.Flags -and ($_.Mask -band $Ace.Mask) -eq $Ace.Mask}).Count -gt 0 +} +function Assert-WelaSelectedSaclPreserved { + param($Before,$After,$Ace) + if($Before.SecurityInformation -ne $After.SecurityInformation -or $Before.DescriptorScope -cne $After.DescriptorScope){throw 'Native descriptor observation scope changed.'} + if($Before.Owner -cne $After.Owner -or $Before.Group -cne $After.Group -or $Before.DaclBase64 -cne $After.DaclBase64 -or ($Before.ControlFlags -band (-bnot 16)) -ne ($After.ControlFlags -band (-bnot 16))){throw 'Non-SACL descriptor components or control flags changed.'} + $counts=New-Object 'System.Collections.Generic.Dictionary[string,int]' ([StringComparer]::Ordinal) + foreach($entry in $After.Aces){if(-not $counts.ContainsKey($entry.Binary)){$counts[$entry.Binary]=0};$counts[$entry.Binary]++} + foreach($entry in $Before.Aces){if(-not $counts.ContainsKey($entry.Binary) -or $counts[$entry.Binary] -lt 1){throw 'An original or unknown ACE changed or disappeared.'};$counts[$entry.Binary]--} + if(-not (Test-WelaSelectedSaclAce $After $Ace)){throw 'Requested audit ACE is absent after write.'} +} +function Assert-WelaSelectedSaclPrerequisites { + param($Definition,$Ace) + $precedence=Get-WelaAuditPrecedenceState + if(-not $precedence.Registry.ValueExists -or $precedence.Registry.Type -ne 'DWord' -or ($precedence.Registry.Value -isnot [int] -and $precedence.Registry.Value -isnot [long]) -or $precedence.Registry.Value -ne 1){throw 'Typed audit precedence DWORD=1 must already be effective; this command never enables it.'} + if($Definition.PolicyMode -eq 'not-applicable' -or ($Definition.PolicyMode -eq 'optional' -and -not $Definition.PolicySelected) -or ($Definition.PolicyMode -eq 'exact' -and $Definition.RequiredPolicyMask -eq 0)){throw 'Selected profile does not select this optional/applicable object-audit requirement.'} + $guid=if($Definition.Kind -eq 'Registry'){'0CCE921E-69AE-11D9-BED3-505054503030'}else{'0CCE921D-69AE-11D9-BED3-505054503030'} + $policies=Get-WelaEffectiveAuditPolicy;$value=$policies[$guid] + if(($value -isnot [int] -and $value -isnot [long]) -or $value -notin @(0,1,2,3) -or ($value -band $Ace.RequiredPolicyMask) -ne $Ace.RequiredPolicyMask){throw 'Required native object-audit outcomes are not already effective; no audit mask is changed.'} +} +function Write-WelaSelectedSaclNative { + param($Definition,$Before,$Ace) + $path=Resolve-WelaSelectedSaclNativePath $Definition + Initialize-WelaSelectedSaclNative + $privilege=New-Object Wela.SelectedSacl.Privilege;$target=$null + try { + $target=New-Object Wela.SelectedSacl.Target($Definition.Kind,$path) + $after=$target.Add($Before.Identity,$Before.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags) + Assert-WelaSelectedSaclPreserved $Before $after $Ace + $after + } finally {if($target){$target.Dispose()};$privilege.Dispose()} +} +function Write-WelaSelectedSaclJson { + param([string]$Path,$Value) + $text=($Value | ConvertTo-Json -Depth 24 -Compress)+[Environment]::NewLine + $bytes=[Text.UTF8Encoding]::new($false).GetBytes($text) + $stream=[IO.File]::Open($Path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} +} +function Resolve-WelaSelectedSaclFilePath { + param([string]$Path) + $provider=$null;$drive=$null + $full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive) + if($provider.Name -ne 'FileSystem'){throw 'A FileSystem path is required.'} + [IO.Path]::GetFullPath($full) +} +function Read-WelaSelectedSaclPlan { + param([string]$Path) + $full=Resolve-WelaSelectedSaclFilePath $Path + $file=Get-Item -LiteralPath $full -ErrorAction Stop + if($file -isnot [IO.FileInfo] -or $file.Length -lt 1 -or $file.Length -gt 4194304 -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Plan must be a regular JSON file of at most 4 MiB.'} + $bytes=[IO.File]::ReadAllBytes($full);if($bytes.Length -gt 4194304){throw 'Plan size changed.'} + $encoding=New-Object Text.UTF8Encoding($false,$true) + $text=$encoding.GetString($bytes).TrimStart([char]0xFEFF) + $module=Get-Module AuditProfiles + $plan=& $module {param($Text) ConvertFrom-WelaCustomProfileJson $Text} $text + if($plan -isnot [pscustomobject] -or ($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or -not $plan.Rows.Count -or $plan.Rows.Count -gt 100){throw 'Invalid selected SACL plan envelope.'} + foreach($property in $plan.PSObject.Properties){if($property.Name -cnotin @('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory')){throw 'Unknown selected SACL plan property.'}} + $seen=@{} + foreach($row in $plan.Rows){ + if($row.Id -isnot [string] -or $row.Id -cnotmatch '^sacl-[0-9a-f]{24}$' -or $seen.ContainsKey($row.Id)){throw 'Invalid or duplicate reviewed target ID.'};$seen[$row.Id]=$true + if((Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey){throw 'Reviewed target definition was modified.'} + $null=Get-WelaSelectedSaclSnapshotKey $row.Before + } + [pscustomobject]@{Path=$full;Hash=(Get-WelaSelectedSaclHash @([Convert]::ToBase64String($bytes)));Plan=$plan} +} +function Assert-WelaSelectedSaclSources { + param($Expected) + $actual=@(Get-WelaSelectedSaclSources) + if(@($Expected).Count -ne $actual.Count){throw 'Plan source inventory differs.'} + for($i=0;$i -lt $actual.Count;$i++){if($Expected[$i].Path -cne $actual[$i].Path -or $Expected[$i].Sha256 -cne $actual[$i].Sha256){throw 'Plan/catalog source changed; generate a new plan.'}} +} +function Assert-WelaSelectedSaclRun { + param($Plan,$Imported) + Assert-WelaSelectedSaclSources $Plan.Sources + if((Get-WelaSelectedSaclContext).Key -cne $Plan.Context.Key){throw 'Actual host context changed or differs from plan.'} + if($Imported -and (Read-WelaSelectedSaclPlan $Imported.Path).Hash -cne $Imported.Hash){throw 'Selected plan file changed.'} +} +function Invoke-WelaSelectedSacl { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string]$Profile,[string[]]$Ids,[string]$PlanPath,[switch]$IncludeOptional,[switch]$IncludeChildren,[switch]$DryRun,[switch]$Auto,[string]$BackupPath,[string]$ResultsPath) + if($DryRun -and $Action -ne 'Configure'){throw 'DryRun requires selected SACL Configure.'} + if($Action -ne 'Configure' -and ($PlanPath -or $Auto -or $BackupPath)){throw 'Plan input, Auto and BackupPath require Configure.'} + if($Action -in @('Plan','Configure') -and -not $Ids){throw 'Explicit nonempty target IDs are required.'} + $selected=@{};foreach($id in $Ids){if($id -cnotmatch '^sacl-[0-9a-f]{24}$' -or $selected.ContainsKey($id)){throw 'Invalid or duplicate selected target ID.'};$selected[$id]=$true} + $output=$null;if($ResultsPath){$output=Resolve-WelaSelectedSaclFilePath $ResultsPath;if(Test-Path -LiteralPath $output){throw 'Results must use a new file.'};if(-not (Test-Path -LiteralPath (Split-Path $output -Parent) -PathType Container)){throw 'Results parent must exist.'}} + $imported=$null + if($Action -eq 'Configure') { + if(-not $PlanPath){throw 'Configure requires a previously reviewed -TargetSaclPlanPath.'} + $imported=Read-WelaSelectedSaclPlan $PlanPath;$prior=$imported.Plan + Assert-WelaSelectedSaclSources $prior.Sources + if($Profile -and $Profile -cne $prior.Profile){throw 'Profile differs from the reviewed plan.'};$Profile=$prior.Profile + if([bool]$IncludeOptional -ne $prior.IncludeOptional -or [bool]$IncludeChildren -ne $prior.IncludeChildren){throw 'Optional/inheritance consent must match the plan explicitly.'} + if(@($prior.Rows).Count -ne $selected.Count -or @($prior.Rows | Where-Object {-not $selected.ContainsKey($_.Id)}).Count){throw 'Configure IDs must match exactly the reviewed plan selection.'} + } + if(-not $Profile){throw 'An explicit built-in -TargetSaclProfile is required for Audit/Plan.'} + $sources=@(Get-WelaSelectedSaclSources);$context=Get-WelaSelectedSaclContext + if($imported -and $context.Key -cne $prior.Context.Key){throw 'Plan belongs to a different actual host context.'} + $catalog=Get-WelaSelectedSaclCatalog -Profile $Profile -IncludeOptional:$IncludeOptional -Context $context + Assert-WelaSelectedSaclSources $sources + foreach($id in $Ids){if(@($catalog.Rows | Where-Object Id -ceq $id).Count -ne 1){throw "Unknown/stale target ID: $id"}} + $rows=@(foreach($item in $catalog.Rows){if(-not $selected.ContainsKey($item.Id)){continue} + $row=[pscustomobject]@{Id=$item.Id;DefinitionKey=$item.DefinitionKey;Definition=$item.Definition;Before=$null;Ace=$null;Status='Blocked';Diagnostic='';After=$null} + try { + $row.Before=Get-WelaSelectedSaclSnapshot $item.Definition + $row.Ace=Get-WelaSelectedSaclAce $item.Definition $row.Before -IncludeChildren:$IncludeChildren + Assert-WelaSelectedSaclPrerequisites $item.Definition $row.Ace + if($imported){ + $old=@($prior.Rows | Where-Object Id -ceq $item.Id)[0] + if($old.DefinitionKey -cne $item.DefinitionKey -or (Get-WelaSelectedSaclSnapshotKey $old.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before) -or + $old.Ace.Sid -cne $row.Ace.Sid -or $old.Ace.Mask -ne $row.Ace.Mask -or $old.Ace.Flags -ne $row.Ace.Flags -or $old.Ace.RequiredPolicyMask -ne $row.Ace.RequiredPolicyMask){throw 'Reviewed target definition/identity/descriptor changed; review a new plan.'} + } + $row.Status=if(Test-WelaSelectedSaclAce $row.Before $row.Ace){'AlreadyCompliant'}else{'ChangeRequired'} + } catch {$row.Diagnostic=$_.Exception.Message} + $row + }) + # Different source entries may identify the same physical key/file. Refuse a + # predictable partial apply: each must be reviewed again after the other write. + $physical=New-Object 'System.Collections.Generic.Dictionary[string,object]' ([StringComparer]::OrdinalIgnoreCase) + foreach($row in $rows){ + if($null -eq $row.Before){continue} + $key=$row.Before.Kind+'|'+$row.Before.Path + if($physical.ContainsKey($key)){ + $row.Status='Blocked';$row.Diagnostic='Multiple selected entries resolve to the same target. Configure one entry, then generate a fresh plan for the other.' + $physical[$key].Status='Blocked';$physical[$key].Diagnostic=$row.Diagnostic + }else{$physical[$key]=$row} + } + $plan=[pscustomobject]@{SchemaVersion=1;Kind='WelaSelectedSaclPlan';CapturedUtc=[DateTime]::UtcNow.ToString('o');Profile=$Profile;IncludeOptional=[bool]$IncludeOptional;IncludeChildren=[bool]$IncludeChildren;Context=$context;Sources=$sources;Rows=$rows;GenerationReadiness='Conditional';UsableRuleCredit=0;Catalog=$(if(-not $Ids){$catalog.Rows}else{@()});UserInventory=$catalog.UserInventory} + Assert-WelaSelectedSaclRun $plan $imported + if($Action -ne 'Configure'){if($output){Write-WelaSelectedSaclJson $output $plan};return $plan} + # Preflight the whole selected set before creating a journal or writing any target. + if(@($rows | Where-Object Status -eq 'Blocked').Count){throw ('Selected SACL preflight failed: '+(@($rows | Where-Object Status -eq 'Blocked' | ForEach-Object Diagnostic) -join '; '))} + $backup=$null + if(-not $DryRun){ + if(-not $BackupPath){throw 'Configure requires an explicit new -BackupPath.'} + $backup=Resolve-WelaSelectedSaclFilePath $BackupPath + if(Test-Path -LiteralPath $backup){throw 'Backup directory must be new.'} + if(-not (Test-Path -LiteralPath (Split-Path $backup -Parent) -PathType Container)){throw 'Backup parent must exist.'} + $null=New-Item -ItemType Directory -Path $backup -ErrorAction Stop + } + foreach($row in $rows){ + if($row.Status -eq 'AlreadyCompliant'){$row.After=$row.Before;continue} + if($DryRun){$row.Status='Skipped';$row.Diagnostic='Dry run; no SACL or recovery file written.';continue} + if(-not $Auto -and (Read-Host "Add the selected audit ACE to $($row.Definition.Path)? (y/N)") -cnotin @('y','Y')){$row.Status='Skipped';$row.Diagnostic='Declined.';continue} + try { + Assert-WelaSelectedSaclRun $plan $imported + Assert-WelaSelectedSaclPrerequisites $row.Definition $row.Ace + $fresh=Get-WelaSelectedSaclSnapshot $row.Definition + if($fresh.Identity -cne $row.Before.Identity -or $fresh.DescriptorBase64 -cne $row.Before.DescriptorBase64){throw 'Target changed before journal/write.'} + $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaSelectedSaclReceipt';State='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');Computer=$context.Computer;ContextKey=$context.Key;Id=$row.Id;Sources=$sources;Definition=$row.Definition;Before=$fresh;Ace=$row.Ace;After=$null} + Write-WelaSelectedSaclJson (Join-Path $backup ($row.Id+'.pending.json')) $receipt + Assert-WelaSelectedSaclRun $plan $imported + Assert-WelaSelectedSaclPrerequisites $row.Definition $row.Ace + $row.After=Write-WelaSelectedSaclNative $row.Definition $fresh $row.Ace + Assert-WelaSelectedSaclPreserved $fresh $row.After $row.Ace + $receipt.State='Confirmed';$receipt.After=$row.After + Write-WelaSelectedSaclJson (Join-Path $backup ($row.Id+'.confirmed.json')) $receipt + $row.Status='Applied' + }catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message} + } + foreach($row in $rows | Where-Object Status -in @('Applied','AlreadyCompliant')){ + try { + Assert-WelaSelectedSaclRun $plan $imported;Assert-WelaSelectedSaclPrerequisites $row.Definition $row.Ace + $fresh=Get-WelaSelectedSaclSnapshot $row.Definition + if($fresh.Identity -cne $row.After.Identity -or $fresh.DescriptorBase64 -cne $row.After.DescriptorBase64){throw 'Final selected target state drifted.'} + }catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message} + } + $report=[pscustomobject]@{SchemaVersion=1;Kind='WelaSelectedSaclResult';ExitCode=$(if(@($rows | Where-Object Status -eq 'Failed').Count){1}else{0});DryRun=[bool]$DryRun;BackupPath=$backup;Plan=$plan;Results=$rows;GenerationReadiness='Conditional';UsableRuleCredit=0} + if($output){Write-WelaSelectedSaclJson $output $report};$report +} diff --git a/scripts/SelectedSaclNative.cs b/scripts/SelectedSaclNative.cs new file mode 100644 index 00000000..0a26ae17 --- /dev/null +++ b/scripts/SelectedSaclNative.cs @@ -0,0 +1,127 @@ +// Handle-bound local file/registry SACL reads and additive audit writes only. +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.SelectedSacl { + public sealed class Ace { + public string Binary; public int Type; public int Flags; public int Mask; public string Sid; public bool Ordinary; + } + public sealed class Snapshot { + public string Path; public string Kind; public string Identity; public bool IsDirectory; + public string DescriptorBase64; public string Owner; public string Group; public string DaclBase64; + public int ControlFlags; public int SecurityInformation; public string DescriptorScope; public Ace[] Aces; + } + public 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 threadToken; + if(OpenThreadToken(GetCurrentThread(),8,true,out threadToken)) {CloseHandle(threadToken);throw new InvalidOperationException("Impersonated callers are outside the selected-SACL workflow.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error,"Cannot establish absence of an impersonation token."); + 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 requested=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required; + bool ok=AdjustTokenPrivileges(token,false,ref requested,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required); + error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is not available."); + }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;public long Accessed;public long Written;public uint Volume;public uint SizeHigh;public uint SizeLow;public uint Links;public uint IndexHigh;public uint 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 value); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out FileInfo information); + [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",CharSet=CharSet.Unicode)] static extern int RegOpenKeyEx(IntPtr key,string path,uint options,uint access,out IntPtr opened); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryValueEx(IntPtr key,string name,IntPtr reserved,out uint type,IntPtr data,ref uint size); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryInfoKey(IntPtr key,IntPtr cls,IntPtr clsSize,IntPtr reserved,IntPtr subKeys,IntPtr maxSubKey,IntPtr maxClass,IntPtr values,IntPtr maxValueName,IntPtr maxValue,IntPtr securitySize,out long written); + [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); + [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); + IntPtr handle;readonly List keys=new List();readonly string path;readonly string kind;readonly uint objectType; + public Target(string kind,string path) { + this.kind=kind;this.path=path;objectType=kind=="FileSystem"?1U:4U; + try { + if(kind=="FileSystem") { + // No DELETE sharing: keep the opened object stable while reading/writing it. + handle=CreateFile(path,0x01020000,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero); + if(handle==new IntPtr(-1)){handle=IntPtr.Zero;throw new Win32Exception(Marshal.GetLastWin32Error());} + FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + if((info.Attributes&0x400)!=0)throw new InvalidOperationException("Reparse-point file target refused."); + StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,final,(uint)final.Capacity,0); + if(length==0||length>=final.Capacity)throw new InvalidOperationException("Cannot verify final local file path."); + if(!String.Equals(final.ToString(),"\\\\?\\"+path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Final handle path differs from selected path (link/redirection)."); + } else if(kind=="Registry") { + string[] parts=path.Split('\\');IntPtr current; + if(parts[0]=="HKEY_LOCAL_MACHINE")current=new IntPtr(unchecked((int)0x80000002)); + else if(parts[0]=="HKEY_USERS")current=new IntPtr(unchecked((int)0x80000003)); + else throw new InvalidOperationException("Only explicitly selected HKLM/HKU keys are supported."); + if(parts.Length<2)throw new InvalidOperationException("A registry hive root cannot be selected."); + for(int i=1;i1048576)throw new InvalidOperationException("Invalid descriptor size.");bytes=new byte[length];Marshal.Copy(descriptor,bytes,0,(int)length);} + finally {LocalFree(descriptor);} + RawSecurityDescriptor sd=new RawSecurityDescriptor(bytes,0);List entries=new List(); + 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}); + } + string identity;bool directory=false; + if(kind=="FileSystem") {FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());directory=(info.Attributes&16)!=0;identity=info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created;} + else {long written;int result=RegQueryInfoKey(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out written);if(result!=0)throw new Win32Exception(result);identity=path+":"+written;} + return new Snapshot {SecurityInformation=(int)securityInformation,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Path=path,Kind=kind,Identity=identity,IsDirectory=directory,DescriptorBase64=Convert.ToBase64String(bytes),Owner=sd.Owner==null?null:sd.Owner.Value,Group=sd.Group==null?null:sd.Group.Value,DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,Aces=entries.ToArray()}; + } + public Snapshot Add(string expectedIdentity,string expectedDescriptor,string sid,int mask,int flags) { + Snapshot before=Read();if(before.Identity!=expectedIdentity||before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Target changed after the recovery snapshot."); + if(mask<=0||(flags&~195)!=0||(flags&192)==0)throw new InvalidOperationException("Invalid selected audit ACE."); + RawSecurityDescriptor sd=new RawSecurityDescriptor(Convert.FromBase64String(before.DescriptorBase64),0); + RawAcl acl=sd.SystemAcl??new RawAcl(2,1); + acl.InsertAce(acl.Count,new CommonAce((AceFlags)flags,AceQualifier.SystemAudit,mask,new SecurityIdentifier(sid),false,null)); + byte[] bytes=new byte[acl.BinaryLength];acl.GetBinaryForm(bytes,0);IntPtr buffer=Marshal.AllocHGlobal(bytes.Length); + try {Marshal.Copy(bytes,0,buffer,bytes.Length);uint error=SetSecurityInfo(handle,objectType,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error);} + finally {Marshal.FreeHGlobal(buffer);} + return Read(); + } + public void Dispose(){if(kind=="FileSystem"&&handle!=IntPtr.Zero)CloseHandle(handle);for(int i=keys.Count-1;i>=0;i--)RegCloseKey(keys[i]);keys.Clear();handle=IntPtr.Zero;} + } +} diff --git a/scripts/TargetedSaclPlanning.ps1 b/scripts/TargetedSaclPlanning.ps1 index a8236b0e..c1cbeac6 100644 --- a/scripts/TargetedSaclPlanning.ps1 +++ b/scripts/TargetedSaclPlanning.ps1 @@ -89,7 +89,7 @@ function Resolve-WelaSaclUserFile { } function Get-WelaSaclTargetObservation { - param([string]$Path, [string]$Kind) + param([string]$Path, [string]$Kind, [switch]$SkipSaclRead) if (-not $Path -or $Path -match '%[^%]+%') { return [pscustomobject]@{ PathState = 'Unknown'; SaclReadState = 'Unknown'; Diagnostic = 'Target path is unresolved.' } } if ($Path.StartsWith('\\')) { return [pscustomobject]@{ PathState = 'RemoteNotInspected'; SaclReadState = 'Unknown'; Diagnostic = 'Network/redirected target requires assessment on the file server; planning does not authenticate to remote paths.' } } try { @@ -116,6 +116,7 @@ function Get-WelaSaclTargetObservation { $state = if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { 'Missing' } else { 'Inaccessible' } return [pscustomobject]@{ PathState = $state; SaclReadState = 'Unknown'; Diagnostic = $_.Exception.Message } } + if ($SkipSaclRead) { return [pscustomobject]@{ PathState = 'Exists'; SaclReadState = 'Not read'; Diagnostic = 'Path preflight only; selected SACL adapter reads security through its native handle.' } } try { $acl = Get-Acl -LiteralPath $Path -Audit -ErrorAction Stop [pscustomobject]@{ PathState = 'Exists'; SaclReadState = 'Readable'; SaclProtected = $acl.AreAuditRulesProtected; Diagnostic = 'SACL can be read. ACE coverage, descendant inheritance and event generation have not been validated.' } @@ -128,6 +129,7 @@ function Get-WelaTargetedSaclPlan { [Parameter(Mandatory)]$AuditPlan, [ValidateSet('Plan', 'Skip')][string]$Mode = 'Plan', [switch]$Live, + [switch]$SkipTargetObservation, [string]$TargetsPath = (Join-Path $PSScriptRoot '../config/audit_sacl_targets.json') ) $definitions = Get-Content -LiteralPath $TargetsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop @@ -163,7 +165,7 @@ function Get-WelaTargetedSaclPlan { } $observation = [pscustomobject]@{ PathState = 'Unknown'; SaclReadState = 'Unknown'; Diagnostic = $detail } if ($Mode -eq 'Skip') { $observation = [pscustomobject]@{ PathState = 'Skipped'; SaclReadState = 'Unknown'; Diagnostic = 'Operator skipped target assessment; telemetry prerequisite remains unverified.' } } - elseif ($Live -and $resolution -in @('Resolved', 'Redirected')) { $observation = Get-WelaSaclTargetObservation -Path $path -Kind $kind } + elseif ($Live -and -not $SkipTargetObservation -and $resolution -in @('Resolved', 'Redirected')) { $observation = Get-WelaSaclTargetObservation -Path $path -Kind $kind } elseif ($Live) { $observation = [pscustomobject]@{ PathState = $resolution; SaclReadState = 'Unknown'; Diagnostic = $detail } } $selected = $policy.mode -in @('exact', 'minimum') -or ($policy.mode -eq 'optional' -and $AuditPlan.includeOptional) $gap = if ($Mode -eq 'Skip') { 'SACL assessment explicitly skipped.' } diff --git a/tests/SelectedSacl.Tests.ps1 b/tests/SelectedSacl.Tests.ps1 new file mode 100644 index 00000000..6b5635c8 --- /dev/null +++ b/tests/SelectedSacl.Tests.ps1 @@ -0,0 +1,159 @@ +$ErrorActionPreference='Stop' +$root=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force +. (Join-Path $root 'scripts/TargetedSaclPlanning.ps1') +. (Join-Path $root 'scripts/SelectedSaclConfiguration.ps1') +$script:count=0 +function Assert($Condition,$Message){if(-not $Condition){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"} +function Clone($Value){$Value|ConvertTo-Json -Depth 24|ConvertFrom-Json} +$script:hostKey='fixture-host';$script:contextReads=0 +function Get-WelaSelectedSaclContext {$script:contextReads++;[pscustomobject]@{Computer='fixture';Role='Client';Build=26100;Detail=[pscustomobject]@{UBR=1};Key=$script:hostKey}} +$script:policy=@{};foreach($row in (Import-WelaAuditProfiles).catalog){$script:policy[$row.guid]=3} +function Get-WelaEffectiveAuditPolicy {$script:policy} +$script:precedence=[pscustomobject]@{Registry=[pscustomobject]@{ValueExists=$true;Type='DWord';Value=1}} +function Get-WelaAuditPrecedenceState {$script:precedence} +function Get-WelaSaclUserInventory {[pscustomobject]@{Users=@();Diagnostics=@('Fixture has no loaded users.');Complete=$true}} +function Get-WelaSaclTargetObservation {throw 'Catalog must not read unselected objects.'} +$catalog=Get-WelaSelectedSaclCatalog -Profile wela-2.2.0 -IncludeOptional -Context (Get-WelaSelectedSaclContext) +Assert ($catalog.Rows.Count -eq 52) 'Shared definitions retain all50 companion targets plus2 separately identified WEF entries without unselected ACL reads.' +$reg=@($catalog.Rows|Where-Object {$_.Definition.Scope -eq 'registry'})[0] +$file=@($catalog.Rows|Where-Object {$_.Definition.Scope -eq 'files'})[0] +Assert ($reg.Definition.PrincipalSid -eq 'S-1-1-0' -and $reg.Definition.AuditFlags.Count -eq 2) 'WELA principal and audit outcomes retained.' +$wef=Get-WelaSelectedSaclCatalog -Profile microsoft-wef-reviewed-2026-09 -Context (Get-WelaSelectedSaclContext) +$wefRows=@($wef.Rows|Where-Object {$_.Definition.Origin -like 'Microsoft WEF*'}) +Assert ($wefRows.Count -eq 2 -and $wefRows[0].Definition.PrincipalSid -eq 'S-1-5-11' -and $wefRows[0].Definition.AuditFlags.Count -eq 1 -and $wefRows[0].Id -ne $reg.Id) 'Exact WEF audit entries remain separate from WELA companion targets.' +$script:states=@{};$script:writes=0;$script:scenario='';$script:currentPlan='';$script:backup='';$script:sourceReader=(Get-Command Get-WelaSelectedSaclSources).ScriptBlock +function Reset-State { + $script:hostKey='fixture-host';$script:writes=0;$script:scenario='' + foreach($item in @($reg,$file)){ + $script:states[$item.Definition.Path]=[pscustomobject]@{SecurityInformation=511;DescriptorScope='WinSDK-defined sections 0x1ff; future sections unobserved';Path=$item.Definition.Path;Kind=$item.Definition.Kind;Identity=$item.Id;IsDirectory=$false;DescriptorBase64=('before-'+$item.Id);Owner='S-1-5-18';Group='S-1-5-18';DaclBase64='retained-dacl';ControlFlags=32788;Aces=@([pscustomobject]@{Binary='Aa==';Type=17;Flags=0;Mask=0;Sid=$null;Ordinary=$false})} + } +} +function Get-WelaSelectedSaclSources { + $sources=@(& $script:sourceReader) + if($script:scenario -eq 'source-race' -and (Test-Path -LiteralPath $script:backup) -and @(Get-ChildItem -LiteralPath $script:backup -Filter '*.pending.json').Count){$sources[0].Sha256='0'*64} + $sources +} +function Get-WelaSelectedSaclSnapshot { + param($Definition) + if(-not $script:states.ContainsKey($Definition.Path)){throw 'Fixture refuses reads of any unselected/broad target.'} + if($script:scenario -eq 'read-denied'){throw 'Selected descriptor access denied'} + Clone $script:states[$Definition.Path] +} +function Write-WelaSelectedSaclNative { + param($Definition,$Before,$Ace) + $script:writes++ + Assert (@(Get-ChildItem -LiteralPath $script:backup -Filter '*.pending.json').Count -ge 1) 'Durable pending receipt exists before native writer.' + if($script:scenario -eq 'write-failure'){throw 'Native SACL write failure'} + $after=Clone $Before + $after.Aces+=@([pscustomobject]@{Binary='audit-added';Type=2;Flags=$Ace.Flags;Mask=$Ace.Mask;Sid=$Ace.Sid;Ordinary=$true}) + $after.DescriptorBase64='after-'+$Before.DescriptorBase64 + if($script:scenario -eq 'dacl-drift'){$after.DaclBase64='someone-else'} + $script:states[$Definition.Path]=$after + $after +} +function Read-Host { + param($Prompt) + if($script:scenario -eq 'decline'){return 'n'} + if($script:scenario -eq 'prompt-race'){$script:states[$reg.Definition.Path].DescriptorBase64='concurrent'} + if($script:scenario -eq 'plan-race'){[IO.File]::AppendAllText($script:currentPlan,[Environment]::NewLine)} + 'y' +} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-selected-sacl-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $temp +function New-Review { + Reset-State + $script:currentPlan=Join-Path $temp ([guid]::NewGuid().ToString('N')+'.json') + $script:backup=Join-Path $temp ([guid]::NewGuid().ToString('N')) + Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0 -IncludeOptional -Ids $reg.Id -IncludeChildren -ResultsPath $script:currentPlan +} +function Apply-Review { + param([switch]$DryRun,[switch]$Auto) + Invoke-WelaSelectedSacl -Action Configure -PlanPath $script:currentPlan -Ids $reg.Id -IncludeOptional -IncludeChildren -BackupPath $script:backup -DryRun:$DryRun -Auto:$Auto +} +try { + $plan=New-Review + Assert ($plan.Rows.Count -eq 1 -and $plan.Rows[0].Status -eq 'ChangeRequired' -and $plan.UsableRuleCredit -eq 0 -and $script:writes -eq 0) 'Plan selects one real catalog definition and remains read-only/Conditional.' + $report=Apply-Review -DryRun + Assert ($script:writes -eq 0 -and -not(Test-Path $script:backup) -and $report.Results[0].Status -eq 'Skipped') 'DryRun does not write SACLs or recovery files.' + $report=Apply-Review -Auto + Assert ($report.ExitCode -eq 0 -and $report.Results[0].Status -eq 'Applied' -and $script:writes -eq 1) 'Selected writer applies and verifies exact requested ACE.' + $receipt=Get-Content (Join-Path $script:backup ($reg.Id+'.confirmed.json')) -Raw|ConvertFrom-Json + Assert ($receipt.State -eq 'Confirmed' -and $receipt.Before.Aces[0].Binary -ceq 'Aa==' -and $receipt.After.DaclBase64 -ceq 'retained-dacl') 'Confirmed receipt retains before/after unknown ACEs and access descriptor.' + $secondPlan=Join-Path $temp 'idempotent.json' + $null=Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0 -IncludeOptional -Ids $reg.Id -IncludeChildren -ResultsPath $secondPlan + $report=Invoke-WelaSelectedSacl -Action Configure -PlanPath $secondPlan -Ids $reg.Id -IncludeOptional -IncludeChildren -BackupPath (Join-Path $temp 'idempotent-backup') -Auto + Assert ($report.Results[0].Status -eq 'AlreadyCompliant' -and $script:writes -eq 1) 'Fresh reviewed re-run avoids duplicate ACE.' + foreach($case in @('prompt-race','plan-race','source-race','decline')){ + $null=New-Review;$script:scenario=$case;$report=Apply-Review + Assert ($script:writes -eq 0 -and $report.Results[0].Status -in @('Failed','Skipped')) "No write after $case." + if($case -ne 'decline'){Assert ($report.ExitCode -eq 1) "$case produces nonzero result."} + } + foreach($case in @('write-failure','dacl-drift')){ + $null=New-Review;$script:scenario=$case;$report=Apply-Review -Auto + Assert ($report.ExitCode -eq 1 -and -not(Test-Path (Join-Path $script:backup ($reg.Id+'.confirmed.json')))) "$case cannot create confirmed ownership receipt." + } + $null=New-Review;$script:states[$reg.Definition.Path].Identity='replaced' + Throws {Apply-Review -Auto} 'preflight failed' + Assert ($script:writes -eq 0 -and -not(Test-Path $script:backup)) 'Changed target identity fails entire preflight before recovery directory.' + $null=New-Review;$script:scenario='read-denied' + Throws {Apply-Review -Auto} 'access denied' + Assert ($script:writes -eq 0) 'Denied selected descriptor is never treated as absent/empty.' + $null=New-Review;$script:hostKey='other-host' + Throws {Apply-Review -Auto} 'different actual host' + $null=New-Review + Throws {Invoke-WelaSelectedSacl -Action Configure -PlanPath $script:currentPlan -Ids $reg.Id -Auto -BackupPath $script:backup} 'consent' + Throws {Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0} 'Explicit nonempty' + Throws {Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0 -IncludeOptional -Ids @($reg.Id,$reg.Id)} 'duplicate' + Throws {Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0 -IncludeOptional -Ids ('sacl-'+('0'*24))} 'Unknown/stale' + $null=New-Review;$bad=Get-Content $script:currentPlan -Raw|ConvertFrom-Json;$bad.Rows[0].Definition.Path='HKLM:\malicious-unreviewed';$bad|ConvertTo-Json -Depth 24|Set-Content $script:currentPlan -Encoding UTF8 + $beforeReads=$script:contextReads;Throws {Apply-Review -Auto} 'definition was modified' + Assert ($beforeReads -eq $script:contextReads) 'Tampered definition rejected before native context/target reads.' + $null=New-Review;$bad=Get-Content $script:currentPlan -Raw|ConvertFrom-Json;$bad.Sources[0].Sha256='bad';$bad|ConvertTo-Json -Depth 24|Set-Content $script:currentPlan -Encoding UTF8 + $beforeReads=$script:contextReads;Throws {Apply-Review -Auto} 'source changed' + Assert ($beforeReads -eq $script:contextReads) 'Source mismatch fails before native host inspection.' + $null=New-Review;[IO.File]::WriteAllText($script:currentPlan,'{"SchemaVersion":1,"schemaVersion":1}') + Throws {Read-WelaSelectedSaclPlan $script:currentPlan} 'Duplicate|colliding' + Reset-State;$snapshot=Get-WelaSelectedSaclSnapshot $reg.Definition + Throws {Get-WelaSelectedSaclAce $reg.Definition $snapshot} 'IncludeChildren' + $incomplete=Clone $snapshot;$incomplete.SecurityInformation=31 + Throws {Get-WelaSelectedSaclSnapshotKey $incomplete} 'observation scope' + $noInheritance=Clone $reg.Definition;$noInheritance.Inheritance='None' + $existing=Clone $snapshot;$existing.Aces[0].Flags=66 + Throws {Get-WelaSelectedSaclAce $noInheritance $existing} 'existing SACL inheritance' + $existingAce=Get-WelaSelectedSaclAce $noInheritance $existing -IncludeChildren + Assert ($existingAce.Flags -eq 192 -and $existing.Aces[0].Flags -eq 66) 'Descendant consent covers existing unknown ACEs without changing source or existing inheritance flags.' + $duplicate=@($catalog.Rows|Where-Object {$_.Definition.Path -eq $reg.Definition.Path -and $_.Id -ne $reg.Id})[0] + Assert ($null -ne $duplicate) 'Real catalog has distinct source/principal entries for the same physical target.' + $duplicatePlan=Join-Path $temp 'duplicate-target.json' + $duplicateBackup=Join-Path $temp 'duplicate-target-backup' + $blocked=Invoke-WelaSelectedSacl -Action Plan -Profile wela-2.2.0 -IncludeOptional -IncludeChildren -Ids @($reg.Id,$duplicate.Id) -ResultsPath $duplicatePlan + Assert (@($blocked.Rows|Where-Object Status -eq 'Blocked').Count -eq 2) 'Duplicate physical target entries are blocked together in the reviewed plan.' + Throws {Invoke-WelaSelectedSacl -Action Configure -PlanPath $duplicatePlan -IncludeOptional -IncludeChildren -Ids @($reg.Id,$duplicate.Id) -BackupPath $duplicateBackup -Auto} 'same target' + Assert ($script:writes -eq 0 -and -not(Test-Path $duplicateBackup)) 'Duplicate source entries cannot produce a predictable partial apply.' + $ace=Get-WelaSelectedSaclAce $reg.Definition $snapshot -IncludeChildren + Assert ($ace.Mask -eq 65542 -and $ace.Flags -eq 194 -and $ace.RequiredPolicyMask -eq 3) 'Exact registry SetValue/CreateSubKey/Delete and success/failure/inheritance masks.' + $wefAce=Get-WelaSelectedSaclAce $wefRows[0].Definition $snapshot -IncludeChildren + Assert ($wefAce.Mask -eq 6 -and $wefAce.Flags -eq 66 -and $wefAce.RequiredPolicyMask -eq 1) 'WEF Run retains SetValue/CreateSubKey success, Authenticated Users and child inheritance.' + $copy=Clone $snapshot;$copy.Aces+=@([pscustomobject]@{Binary='new';Type=2;Flags=194;Mask=65542;Sid='S-1-1-0';Ordinary=$true}) + Assert (Test-WelaSelectedSaclAce $copy $ace) 'Exact ordinary ACE matches.' + $copy.Aces[1].Flags=210;Assert (-not(Test-WelaSelectedSaclAce $copy $ace)) 'Inherited ACE is preserved but never mistaken for explicit requested entry.' + $copy.Aces[1].Flags=194;$copy.Aces[0].Binary='aa==' + Throws {Assert-WelaSelectedSaclPreserved $snapshot $copy $ace} 'unknown ACE' + foreach($kind in @('String','DWord')){ + $script:precedence.Registry.Type=$kind;$script:precedence.Registry.Value='1' + Throws {Assert-WelaSelectedSaclPrerequisites $reg.Definition $ace} 'Typed audit precedence' + } + $script:precedence.Registry.Type='DWord';$script:precedence.Registry.Value=1 + $guid='0CCE921E-69AE-11D9-BED3-505054503030';$script:policy[$guid]=1 + Throws {Assert-WelaSelectedSaclPrerequisites $reg.Definition $ace} 'outcomes' + $script:policy[$guid]=3 + $exe=(Get-Process -Id $PID).Path + foreach($arguments in @(@('configure','-TargetSaclAction','Audit'),@('targeted-sacl','-Profile','wela-2.2.0'),@('targeted-sacl','-DryRun'))){ + $ErrorActionPreference='Continue';try{$output=& $exe -NoProfile -File (Join-Path $root 'WELA.ps1') @arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'} + $plain=($output -join ' ') -replace '\x1b\[[0-9;]*[A-Za-z]','' -replace '[|\r\n]',' ' + Assert ($code -ne 0 -and $plain -match 'No\s+command\s+was\s+run') 'Public guards refuse unrelated commands and ignored DryRun.' + } + Write-Host "PASS: $script:count selected-SACL mocked assertions. Native target reads/writes are replaced; no machine ACL or policy mutations." +}finally{Remove-Item -LiteralPath $temp -Recurse -Force} +$global:LASTEXITCODE=0 diff --git a/tests/SelectedSacl.Windows.Tests.ps1 b/tests/SelectedSacl.Windows.Tests.ps1 new file mode 100644 index 00000000..cc11c65d --- /dev/null +++ b/tests/SelectedSacl.Windows.Tests.ps1 @@ -0,0 +1,79 @@ +param([switch]$AllowDisposableSaclWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableSaclWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'This mutating fixture requires explicit opt-in on a disposable GitHub-hosted Windows runner.'} +$root=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force +. (Join-Path $root 'scripts/Configuration.ps1') +. (Join-Path $root 'scripts/TargetedSaclPlanning.ps1') +. (Join-Path $root 'scripts/SelectedSaclConfiguration.ps1') +$script:count=0 +function Assert($Condition,$Message){if(-not $Condition){throw $Message};$script:count++} +function Fingerprint($Map){(@($Map.Keys|Sort-Object|ForEach-Object{"$_=$($Map[$_])"}) -join ';')} +$beforePolicy=Get-WelaEffectiveAuditPolicy +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' +$beforePrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy +$privilegeBefore=(Invoke-WelaNative whoami.exe @('/priv','/fo','csv')).Diagnostic +$nonce=[guid]::NewGuid().ToString('N');$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-sacl-'+$nonce) +$regSub='Software\WELASelectedSacl_'+$nonce;$regProvider='HKCU:\'+$regSub +$file=Join-Path $temp 'probe.txt';$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$policyGuids=@('0CCE921D-69AE-11D9-BED3-505054503030','0CCE921E-69AE-11D9-BED3-505054503030') +$restored=$false +try { + $null=New-Item -ItemType Directory -Path $temp + [IO.File]::WriteAllText($file,'WELA selected-SACL disposable fixture') + $null=New-Item -Path $regProvider + $fileDefinition=[pscustomobject]@{Path=$file;Kind='FileSystem';Resolution='Resolved';PrincipalSid='S-1-1-0';Propagation='None';Inheritance='None';Rights=@('ReadData');AuditFlags=@('Success');PolicyMode='minimum';PolicySelected=$true;RequiredPolicyMask=1} + $regDefinition=[pscustomobject]@{Path=('Registry::HKEY_USERS\'+$sid+'\'+$regSub);Kind='Registry';Resolution='Resolved';PrincipalSid='S-1-1-0';Propagation='None';Inheritance='None';Rights=@('SetValue');AuditFlags=@('Success');PolicyMode='minimum';PolicySelected=$true;RequiredPolicyMask=1} + Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord + foreach($guid in $policyGuids){Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum} + $snapshots=@{} + foreach($definition in @($fileDefinition,$regDefinition)){ + Write-Host ("Reading full native descriptor for owned "+$definition.Kind+" fixture.") + $before=Get-WelaSelectedSaclSnapshot $definition + Assert ($before.SecurityInformation -eq 511 -and $before.DescriptorScope -match 'future sections unobserved') 'Native receipt records all current SDK sections with its bounded observation scope.' + $ace=Get-WelaSelectedSaclAce $definition $before + Assert (-not(Test-WelaSelectedSaclAce $before $ace)) 'Fresh owned target has no requested audit ACE.' + Assert-WelaSelectedSaclPrerequisites $definition $ace + $after=Write-WelaSelectedSaclNative $definition $before $ace + Assert-WelaSelectedSaclPreserved $before $after $ace + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Native readback is stable after additive SACL write.' + Assert (Test-WelaSelectedSaclAce $after $ace) 'Requested explicit native audit ACE matches on re-read, providing idempotence input.' + $snapshots[$definition.Kind]=$after + $caught='';try{Write-WelaSelectedSaclNative $definition $before $ace|Out-Null}catch{$caught=$_.Exception.Message} + Assert ($caught -match 'changed after') 'Stale descriptor is refused by the real native handle writer.' + } + $started=[DateTime]::UtcNow.AddSeconds(-1) + $null=[IO.File]::ReadAllText($file) + $valueName='Probe_'+$nonce + New-ItemProperty -LiteralPath $regProvider -Name $valueName -PropertyType String -Value $nonce | Out-Null + $found=@{};$deadline=[DateTime]::UtcNow.AddSeconds(20) + while($found.Count -lt 2 -and [DateTime]::UtcNow -lt $deadline){ + $events=@();try{$events=@(Get-WinEvent -FilterHashtable @{LogName='Security';Id=@(4657,4663);StartTime=$started} -MaxEvents 512 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notmatch 'NoMatchingEventsFound'){throw}} + foreach($event in $events){ + try { + $xml=[xml]$event.ToXml();$data=@{};foreach($node in $xml.Event.EventData.Data){$data[[string]$node.Name]=[string]$node.'#text'} + if($event.ProviderName -ne 'Microsoft-Windows-Security-Auditing'){continue} + if($event.Id -eq 4663 -and $data.ObjectName -ieq $file -and [Convert]::ToInt64(($data.ProcessId -replace '^0x',''),16) -eq $PID){$found.FileSystem=$event.ToXml()} + $expectedRegistry='\REGISTRY\USER\'+$sid+'\'+$regSub + if($event.Id -eq 4657 -and $data.ObjectName -ieq $expectedRegistry -and $data.ObjectValueName -ceq $valueName -and $data.NewValue -ceq $nonce){$found.Registry=$event.ToXml()} + }finally{if($event -is [IDisposable]){$event.Dispose()}} + } + if($found.Count -lt 2){Start-Sleep -Milliseconds 250} + } + Assert ($found.ContainsKey('FileSystem')) 'Benign exact file read generated matched native4663 XML.' + Assert ($found.ContainsKey('Registry')) 'Benign unique registry value write generated matched native4657 XML.' + foreach($kind in @('FileSystem','Registry')){Write-Host ("Native fixture evidence "+$kind+': '+$found[$kind])} + Assert ((Invoke-WelaNative whoami.exe @('/priv','/fo','csv')).Diagnostic -ceq $privilegeBefore) 'Native target adapters restore process privilege state after success and refused writes.' + Write-Host "PASS: $script:count actual selected-SACL assertions on owned disposable targets only; no Sigma/backend/descendant claim." +} finally { + foreach($guid in $policyGuids){Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforePolicy[$guid] -Mode exact} + if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $beforePrecedence.Type -Value $beforePrecedence.Value} + else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue} + $afterPolicy=Get-WelaEffectiveAuditPolicy;$afterPrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy + if((Fingerprint $beforePolicy) -cne (Fingerprint $afterPolicy) -or ($beforePrecedence|ConvertTo-Json -Compress) -cne ($afterPrecedence|ConvertTo-Json -Compress)){throw "Fixture policy restoration failed; retain owned evidence at $temp and $regProvider."} + if(Test-Path -LiteralPath $regProvider){Remove-Item -LiteralPath $regProvider -Recurse -Force} + if(Test-Path -LiteralPath $temp){Remove-Item -LiteralPath $temp -Recurse -Force} + $restored=$true + Write-Host 'PASS: all59 native audit masks and typed precedence restored; only owned disposable targets removed.' +} +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index ae56ed18..521a4927 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 既存のローカルファイル・レジストリを明示的に選択して監査・計画・設定する`targeted-sacl`を追加しました。出典ごとの監査ACE、実効ポリシーの前提条件、継承の個別同意を確認し、対象ハンドルを使ってSACLだけを更新します。既存のセキュリティ記述子を保持し、変更前と検証済みの記録、最終状態の確認、特権の復元に対応します。使い捨てオブジェクトのネイティブテストを追加し、子孫全体・転送・Sigmaの利用可能性は別途検証が必要です。 (#422) (@Shirofune-Security) - 既存CA向けにネイティブの`adcs-auditing`監査・計画・出典付き設定を追加しました。CAと証明書の識別、監査の前提条件、型付き復旧記録、変更直前と読戻しの検証を共有し、従来のCA設定も同じ処理を使用します。停止中のCAは起動せず、専用コマンドでのフィルター変更には再起動の明示指定を求めます。設定一致・再起動の観測・イベント証拠を区別し、Sigma利用可能数には加算しません。使い捨てのスタンドアロンCAテストで保留要求の4886/4889 XMLを関連付け、元の監査設定と作成した資源を復元・削除します。エンタープライズCA・DC・収集基盤の検証は別途必要です。 (#421) (@Shirofune-Security) - `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 4e46e49e..f101e697 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added opt-in `targeted-sacl` auditing, reviewed plans and selective configuration for existing local file/registry targets. Source-specific audit ACEs, policy prerequisites, inheritance consent, handle-bound SACL-only writes, preserved security descriptors, pending/confirmed receipts and final checks keep target scope explicit. Privileges are restored; native disposable-object tests cover file/registry events without claiming descendant, forwarding or Sigma readiness. (#422) (@Shirofune-Security) - Added dedicated native `adcs-auditing` audit, plan and source-profile configuration, with pinned CA/certificate identity, verified audit prerequisites, typed journals and fresh/readback guards. Legacy CA configuration uses the same engine; stopped CAs are preserved and dedicated filter changes require explicit restart consent. Policy matches, observed restarts and request events remain separate, with no Sigma credit. Disposable standalone-CA tests collect correlated pending-request 4886/4889 XML and restore policy/created resources; enterprise/DC/backend acceptance remains separate. (#421) (@Shirofune-Security) - Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security)