From aa4630dda9e845ce9d120fea863e26237988611a Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:34:05 +0900 Subject: [PATCH 1/9] Add scoped native 4688 command-line policy configuration --- .gitattributes | 3 + .github/workflows/process-commandline.yml | 48 +++++++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 1 + WELA.ps1 | 17 ++- docs/process-commandline.md | 26 +++++ scripts/Configuration.ps1 | 2 +- scripts/ProcessCommandline.ps1 | 95 +++++++++++++++++ tests/ProcessCommandline.Cli.Tests.ps1 | 19 ++++ tests/ProcessCommandline.Tests.ps1 | 60 +++++++++++ tests/ProcessCommandline.Windows.Tests.ps1 | 115 +++++++++++++++++++++ website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 1 + 14 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/process-commandline.yml create mode 100644 docs/process-commandline.md create mode 100644 scripts/ProcessCommandline.ps1 create mode 100644 tests/ProcessCommandline.Cli.Tests.ps1 create mode 100644 tests/ProcessCommandline.Tests.ps1 create mode 100644 tests/ProcessCommandline.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 40c44920..74afb33e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -110,3 +110,6 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf # Public filesystem-SACL disposable lifecycle evidence. tests/FileSaclProfileFixture.cs text eol=lf tests/FileSaclLifecycle.Windows.Tests.ps1 text eol=lf + +/scripts/ProcessCommandline.ps1 text eol=lf +/tests/ProcessCommandline* text eol=lf diff --git a/.github/workflows/process-commandline.yml b/.github/workflows/process-commandline.yml new file mode 100644 index 00000000..387f4e68 --- /dev/null +++ b/.github/workflows/process-commandline.yml @@ -0,0 +1,48 @@ +name: Scoped process command-line auditing +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/ProcessCommandline.ps1' + - 'scripts/Configuration.ps1' + - 'scripts/NativeValidation.ps1' + - 'scripts/ControlApplicability.ps1' + - 'tests/ProcessCommandline*' + - '.github/workflows/process-commandline.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + process-commandline: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Scoped audit tests in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/ProcessCommandline.Tests.ps1 + ./tests/ProcessCommandline.Cli.Tests.ps1 + ./tests/ProcessCommandline.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Scoped audit tests in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/ProcessCommandline.Tests.ps1 + ./tests/ProcessCommandline.Cli.Tests.ps1 + ./tests/ProcessCommandline.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Retain typed originals, results and cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: process-commandline-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-process-commandline-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 544eba1a..a5aa6678 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./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, ./docs/native-filesystem-sacl-validation.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/process-commandline.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 764fd355..baf72452 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 組み込みの Security 4688 コマンドライン記録ポリシーのみを扱う `process-commandline` の Audit/Plan/Configure を追加しました。変更前の型付き状態、競合検出、監査の前提条件の分離、Windows 上の設定とイベント検証に対応します。#364、#365、#387 に関連します。 - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 195d6774..719c5e11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added `process-commandline` Audit/Plan/Configure for only the built-in Security 4688 command-line policy, with typed originals, drift checks, separate audit prerequisites and native Windows configuration/event validation. Related to #364, #365 and #387. - Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 7ece9a25..d1e47de9 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -1,5 +1,6 @@ param ( [string]$Cmd, + [ValidateSet("Audit","Plan","Configure")][string]$ProcessCommandlineAction = "Audit", [string]$OutType = "std", [switch]$Debug, [string]$Baseline, @@ -243,6 +244,7 @@ $AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt" $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") . (Join-Path $ScriptRoot "scripts/OutgoingNtlmAudit.ps1") +. (Join-Path $ScriptRoot "scripts/ProcessCommandline.ps1") . (Join-Path $ScriptRoot "scripts/NtlmAudit.ps1") . (Join-Path $ScriptRoot "scripts/AdcsAuditing.ps1") . (Join-Path $ScriptRoot "scripts/AdcsRestartResume.ps1") @@ -2088,6 +2090,7 @@ Usage: ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation ./WELA.ps1 ntlm-auditing -Help # Configure selected incoming/domain NTLM auditing + ./WELA.ps1 process-commandline -Help ./WELA.ps1 outgoing-ntlm -Help # Configure outgoing NTLM auditing independently ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription @@ -2198,6 +2201,11 @@ if ($Cmd -eq 'ntlm-auditing') { if ($NtlmAuditAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAuditAction Configure.'} if ($NtlmAuditAction -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('NtlmAuditScope')) {throw 'Configure requires explicit NtlmAuditScope Incoming, Domain or Both.'} } +if ($Cmd -ne 'process-commandline' -and $PSBoundParameters.ContainsKey('ProcessCommandlineAction')) {throw 'ProcessCommandlineAction requires process-commandline.'} +if ($Cmd -eq 'process-commandline') { + if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','ProcessCommandlineAction','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'process-commandline accepts only its dedicated options.'} + if ($ProcessCommandlineAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require ProcessCommandlineAction Configure.'} +} if ($Cmd -ne 'outgoing-ntlm' -and $PSBoundParameters.ContainsKey('NtlmAction')) {throw 'NtlmAction requires outgoing-ntlm.'} if ($Cmd -eq 'outgoing-ntlm') { if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAction','OutgoingNtlmMode','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'outgoing-ntlm accepts only its dedicated options.'} @@ -2294,7 +2302,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 'process-commandline' -and $ProcessCommandlineAction -eq 'Configure') -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 @@ -2487,6 +2495,13 @@ switch ($Cmd.ToLower()) { $report|Format-List exit $report.ExitCode } + 'process-commandline' { + if ($Help) {Write-Host 'Usage: process-commandline [-ProcessCommandlineAction Audit|Plan|Configure] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Enables only command-line inclusion for Security4688. Audit Process Creation and precedence are separate prerequisites. See docs/process-commandline.md.';return} + if ($ProcessCommandlineAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'Command-line policy configuration requires Administrator privileges.'} + $report=Invoke-WelaProcessCommandline -Action $ProcessCommandlineAction -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report|Format-List + exit $report.ExitCode + } 'outgoing-ntlm' { if ($Help) {Write-Host 'Usage: outgoing-ntlm [-NtlmAction Audit|Plan|Configure] [-OutgoingNtlmMode PreserveOrAudit|Audit] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Changes only the outgoing audit DWORD. Existing deny is preserved by default; explicit Audit authorizes replacing it. See docs/outgoing-ntlm.md.';return} if ($NtlmAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'Outgoing NTLM configuration requires Administrator privileges.'} diff --git a/docs/process-commandline.md b/docs/process-commandline.md new file mode 100644 index 00000000..14845a01 --- /dev/null +++ b/docs/process-commandline.md @@ -0,0 +1,26 @@ +# Scoped Security 4688 command-line policy + +`process-commandline` reads or enables only the native `ProcessCreationIncludeCmdLine_Enabled` DWORD under `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit`. Its default Audit action is read-only. Configure is an explicit choice and requires an administrator; it does not invoke the broad `configure` workflow. + +```powershell +./WELA.ps1 process-commandline -ResultsPath commandline-audit.json +./WELA.ps1 process-commandline -ProcessCommandlineAction Plan -ResultsPath commandline-plan.json +./WELA.ps1 process-commandline -ProcessCommandlineAction Configure -DryRun +./WELA.ps1 process-commandline -ProcessCommandlineAction Configure -Auto -BackupPath C:\WelaBackups\commandline-001 -ResultsPath commandline-result.json +``` + +The supported host processes are native 64-bit Windows PowerShell 5.1 and PowerShell 7. The policy applies to Windows process creation, including programs launched from either engine; it is independent of PowerShell script-block/module logging and transcription. Actual Windows build, product type, join state and domain role must agree. Reviewed build families are Windows 11 22000/22621/22631/26100/26200 and Server 2022/2025 20348/26100. WMI must already be running; the command does not start it. Unknown or contradictory observations refuse configuration. Role/build overrides, source-profile selection and other command options are rejected. + +[Microsoft documents the exact registry mapping](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-auditsettings). The independent [Audit Process Creation prerequisite](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing) must have success auditing enabled to generate 4688. The report reads its effective mask and audit precedence separately, without changing either. `SuccessMissing` or `Unknown` remains visible even if the command-line DWORD is enabled. Policy values absent/0 require change; DWORD1 is already compliant. Other values/types are preserved as unknown. If needed, only the final `Audit` subkey may be created beneath the existing `System` policy key, without `New-Item -Force`. + +Command-line arguments are recorded as plain text in Security events and can include passwords or personal data. Review access to the Security log and avoid passing secrets as arguments. This is a property of the requested logging setting, not a claim that WELA filters sensitive data. + +Before a write, `before.jsonl` stores the exact typed prior value/absence and observed host/unrelated child-key state. A new backup directory is required. Plan-to-read and prewrite comparisons reject drift; immediate and final readbacks distinguish Applied, AlreadyCompliant, Skipped, Failed and Overridden. DryRun makes no registry or recovery-directory changes. Unknown reads, failed writes, journal failures and result serialization failures cannot report success. Operations are not an atomic transaction with GPO/MDM/other administrators. The winning policy source and future persistence remain unknown; the command neither refreshes GPO nor edits domain policy. Unrelated values/subkeys are preserved and checked, not replaced. + +For manual recovery, protect the journal and results, verify which write completed and compare the current value with the recorded After state. Restore only this exact DWORD's original type/value or absence if no later policy owns the change. Remove a newly created key only when the journal proves prior absence and the current key is still empty. Never replace the whole System policy key or restore unrelated values. + +## Native validation + +The disposable hosted Windows test exercises the public CLI on actual standalone Server 2022/2025 under both engines: absent/disabled state, Plan, DryRun, one-value Configure, exact original journal, idempotence, native readback and separate missing-prerequisite reporting. The fixture independently prepares Process Creation success and audit precedence, then verifies that the public command leaves all59 masks, precedence, other values, Security channel and service states unchanged. A separate existing fixed `cmd.exe /d /c echo` probe collects a precisely matched 4688 with command line. Artifact hashes, source fingerprints, exact builds/engines and final cleanup are retained. The fixture restores typed policies/key absence and all original audit masks; failed cleanup fails CI. + +The command itself generates no probe event. Policy compliance is not proof of event generation, forwarding, backend normalization or a complete Sigma rule. `ReadyRuleCredit=0`. Hosted standalone-server evidence does not establish Windows11, domain-member, DC, ADCS, GPO or cross-host behavior. Sysmon is out of scope. See the [4688 schema](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4688) and [native validation guide](native-validation.md). diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 5202f504..d77b444c 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] + [ValidateSet("native-windows-configuration", "process-commandline-policy-only", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { diff --git a/scripts/ProcessCommandline.ps1 b/scripts/ProcessCommandline.ps1 new file mode 100644 index 00000000..1c61f6bf --- /dev/null +++ b/scripts/ProcessCommandline.ps1 @@ -0,0 +1,95 @@ +# Scoped built-in Security 4688 command-line policy. This does not set audit masks. +function Get-WelaProcessCommandlineSnapshot { + if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'Use 64-bit PowerShell on Windows.'} + if ((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running') {throw 'Existing Windows Management Instrumentation must be running; it will not be started.'} + $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop + $cs=Get-CimInstance Win32_ComputerSystem -Property DomainRole,PartOfDomain -ErrorAction Stop + $build=[int]$os.BuildNumber;$product=[int]$os.ProductType;$role=[int]$cs.DomainRole + if ($cs.PartOfDomain -isnot [bool] -or $role -notin 0,1,2,3,4,5 -or + -not (($product -eq 1 -and $role -in 0,1 -and $build -in 22000,22621,22631,26100,26200) -or + ($product -eq 2 -and $role -in 4,5 -and $build -in 20348,26100) -or + ($product -eq 3 -and $role -in 2,3 -and $build -in 20348,26100)) -or + ($cs.PartOfDomain -ne ($role -in 1,3,4,5))) {throw 'Unknown, unsupported or contradictory Windows role/build/join context.'} + $path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit' + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + $parent=$null;$key=$null + try { + $parent=$base.OpenSubKey('SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System') + if (-not $parent) {throw 'The existing System policy parent is required.'} + $key=$parent.OpenSubKey('Audit') + $unselected=[pscustomobject][ordered]@{Values=@();Children=@()} + if ($key) { + $unselected.Values=@($key.GetValueNames()|Sort-Object|Where-Object {$_ -ine 'ProcessCreationIncludeCmdLine_Enabled'}|ForEach-Object { + [pscustomobject][ordered]@{Name=$_;Type=$key.GetValueKind($_).ToString();Value=$key.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)} + }) + $unselected.Children=@($key.GetSubKeyNames()|Sort-Object) + } + } finally {if($key){$key.Dispose()};if($parent){$parent.Dispose()};$base.Dispose()} + [pscustomobject][ordered]@{ + Host=[pscustomobject][ordered]@{Build=$build;ProductType=$product;DomainRole=$role;PartOfDomain=$cs.PartOfDomain} + Policy=Get-WelaRegistryState $path ProcessCreationIncludeCmdLine_Enabled + Unselected=$unselected + } +} + +function Get-WelaProcessCommandlineDisposition { + param($Snapshot) + $p=$Snapshot.Policy + if ($p.ValueExists -and ($p.Type -cne 'DWord' -or $p.Value -notin 0,1)) {return 'Unknown'} + if ($p.ValueExists -and $p.Value -eq 1) {return 'AlreadyCompliant'} + return 'ChangeRequired' +} + +function Get-WelaProcessCommandlinePrerequisite { + try { + $m=Get-WelaEffectiveAuditPolicy;$guid='0cce922b-69ae-11d9-bed3-505054503030' + if (-not $m.ContainsKey($guid) -or $m[$guid] -notin 0,1,2,3) {throw 'Process Creation mask is unavailable.'} + [pscustomobject]@{State=$(if($m[$guid] -band 1){'SuccessEnabled'}else{'SuccessMissing'});Mask=$m[$guid];Precedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy;Diagnostic='Observed only. This command does not change audit policy or precedence.'} + } catch {[pscustomobject]@{State='Unknown';Mask=$null;Precedence=$null;Diagnostic=$_.ToString()}} +} + +function Get-WelaProcessCommandlinePlan { + try { + $snapshot=Get-WelaProcessCommandlineSnapshot + $status=Get-WelaProcessCommandlineDisposition $snapshot + [pscustomobject]@{Status=$status;Before=$snapshot;Desired=1;Prerequisite=Get-WelaProcessCommandlinePrerequisite;PolicySource='Unknown: local registry observation does not identify the winning GPO or MDM policy.';Diagnostic=$(if($status -eq 'Unknown'){'Unknown registry type/value is preserved.'}else{'Enable only the Security 4688 command-line DWORD. Arguments are recorded as plain text and may contain sensitive data.'})} + } catch {[pscustomobject]@{Status='Unknown';Before=$null;Desired=1;Prerequisite=$null;PolicySource='Unknown';Diagnostic=$_.ToString()}} +} + +function Invoke-WelaProcessCommandline { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + if ($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require Configure.'} + $plan=Get-WelaProcessCommandlinePlan + if ($Action -eq 'Configure') { + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + $path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit';$name='ProcessCreationIncludeCmdLine_Enabled' + if ($plan.Status -eq 'Unknown') { + $context.Results.Add([pscustomobject]@{Id="Registry/$path/$name";Kind='Registry';Target=@{Path=$path;Name=$name};Desired=@{Value=1;Type='DWord'};Before=$plan.Before;After=$null;Status='Failed';Diagnostic=$plan.Diagnostic}) + } else { + $state=@{Observed=$null;Planned=($plan.Before|ConvertTo-Json -Depth 12 -Compress);Preserved=([ordered]@{Host=$plan.Before.Host;Unselected=$plan.Before.Unselected}|ConvertTo-Json -Depth 12 -Compress);Path=$path;Name=$name;First=$true} + $read={param($s) + $snapshot=Get-WelaProcessCommandlineSnapshot + if ((Get-WelaProcessCommandlineDisposition $snapshot) -eq 'Unknown') {throw 'Unknown registry type/value is preserved.'} + if (([ordered]@{Host=$snapshot.Host;Unselected=$snapshot.Unselected}|ConvertTo-Json -Depth 12 -Compress) -cne $s.Preserved) {throw 'Host or unrelated policy values/subkeys changed; review a new plan.'} + if ($s.First -and ($snapshot|ConvertTo-Json -Depth 12 -Compress) -cne $s.Planned) {throw 'Policy changed after planning; review a new plan.'} + $s.First=$false;$s.Observed=$snapshot;return $snapshot + } + $test={param($snapshot) $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq 1} + $apply={param($s) + $fresh=Get-WelaProcessCommandlineSnapshot + if (($fresh|ConvertTo-Json -Depth 12 -Compress) -cne ($s.Observed|ConvertTo-Json -Depth 12 -Compress)) {throw 'Policy changed after its original journal; no write attempted.'} + if ((Get-WelaProcessCommandlineDisposition $fresh) -ne 'ChangeRequired') {throw 'Current state no longer authorizes this write.'} + if (-not $fresh.Policy.KeyExists) {$null=New-WelaRegistryKey -Path $s.Path} + Set-ItemProperty -LiteralPath $s.Path -Name $s.Name -Value 1 -Type DWord -ErrorAction Stop + 'Requested only command-line inclusion. Process Creation success auditing remains a separate prerequisite.' + } + Invoke-WelaConfigurationControl -Context $context -Id "Registry/$path/$name" -Kind Registry -Target @{Path=$path;Name=$name} -Desired @{Value=1;Type='DWord'} -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description $plan.Diagnostic + } + $report=Complete-WelaConfiguration -Context $context -Scope 'process-commandline-policy-only' -SuccessMessage 'Command-line policy results recorded; inspect prerequisites and skipped controls separately.' + $report|Add-Member NoteProperty Plan $plan + } else {$report=[pscustomobject]@{ExitCode=$(if($plan.Status -eq 'Unknown'){1}else{0});Action=$Action;Scope='process-commandline-policy-only';Plan=$plan}} + $report|Add-Member NoteProperty EventGeneration 'Unverified: policy readback is not 4688, field, forwarding, GPO persistence or complete-rule evidence.' + $report|Add-Member NoteProperty ReadyRuleCredit 0 + if ($ResultsPath) {try {$report|ConvertTo-Json -Depth 20|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop}catch{$report.ExitCode=1;Write-Host "[Failed] Writing command-line policy results: $_" -ForegroundColor Red}} + return $report +} diff --git a/tests/ProcessCommandline.Cli.Tests.ps1 b/tests/ProcessCommandline.Cli.Tests.ps1 new file mode 100644 index 00000000..6c4956a4 --- /dev/null +++ b/tests/ProcessCommandline.Cli.Tests.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-cli-'+[guid]::NewGuid().ToString('N')) +$cases=@( + @{Args=@('process-commandline','-ProcessCommandlineAction','Configure','-DryRun','-Help');Code=0;Pattern='Enables only'}, + @{Args=@('process-commandline','-Help');Code=0;Pattern='Enables only'}, + @{Args=@('configure','-ProcessCommandlineAction','Configure');Code=1;Pattern='requires process-commandline'}, + @{Args=@('process-commandline','-OutgoingNtlmMode','Deny');Code=1;Pattern='dedicated options'}, + @{Args=@('process-commandline','-Role','Client');Code=1;Pattern='dedicated options'}, + @{Args=@('process-commandline','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'}, + @{Args=@('process-commandline','-Auto');Code=1;Pattern='options require'}, + @{Args=@('process-commandline','-DryRun');Code=1;Pattern='options require'}, + @{Args=@('process-commandline','-BackupPath',$root);Code=1;Pattern='options require'}, + @{Args=@('process-commandline','-Help','-ProviderAction','Configure');Code=1;Pattern='dedicated options'}, + @{Args=@('process-commandline','-ProcessCommandlineAction','Configure','-Typo');Code=1;Pattern='Unsupported trailing arguments'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($text -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($text -join ' ')"};$count++} +if(Test-Path $root){throw 'Refused CLI input created unexpected output.'} +Write-Host "PASS: $count scoped process command-line CLI guards." +exit 0 diff --git a/tests/ProcessCommandline.Tests.ps1 b/tests/ProcessCommandline.Tests.ps1 new file mode 100644 index 00000000..996e55c1 --- /dev/null +++ b/tests/ProcessCommandline.Tests.ps1 @@ -0,0 +1,60 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/ProcessCommandline.ps1') +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$count=0;$sequence=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 12 -Compress} +function Reset($Value,$Type='DWord'){ + $script:policy=[pscustomobject][ordered]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})} + $script:unselected=[pscustomobject]@{Values=@();Children=@()};$script:writes=0;$script:reads=0;$script:failRead=$false;$script:failWrite=$false;$script:ignoreWrite=$false;$script:promptChange=$null;$script:onRead=$null +} +function Get-WelaProcessCommandlineSnapshot { + $script:reads++;if($script:onRead){& $script:onRead};if($script:failRead){throw 'Access denied'} + [pscustomobject][ordered]@{Host=[pscustomobject][ordered]@{Build=26100;ProductType=3;DomainRole=2;PartOfDomain=$false};Policy=($script:policy|ConvertTo-Json|ConvertFrom-Json);Unselected=($script:unselected|ConvertTo-Json -Depth 12|ConvertFrom-Json)} +} +function Get-WelaProcessCommandlinePrerequisite {[pscustomobject]@{State='SuccessMissing';Mask=0}} +function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) + Assert ($LiteralPath -ceq 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit' -and $Name -ceq 'ProcessCreationIncludeCmdLine_Enabled' -and $Value -eq 1 -and $Type -ceq 'DWord') 'Only the one exact audit-only target may be written.' + $script:writes++;if($script:failWrite){throw 'Write denied'};if(-not $script:ignoreWrite){$script:policy.ValueExists=$true;$script:policy.Value=1;$script:policy.Type='DWord'} +} +function New-WelaRegistryKey {param($Path) Assert (-not $script:policy.KeyExists) 'Only an absent key may be created.';$script:policy.KeyExists=$true} +function Read-Host {param($Prompt) if($script:promptChange){& $script:promptChange};return 'Y'} +function Configure([switch]$DryRun,[switch]$Prompt){ + $script:sequence++;$script:backup=Join-Path $root ('case-'+$script:sequence) + Invoke-WelaProcessCommandline -Action Configure -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath $script:backup +} +try{ + foreach($initial in @($null,0,1)){ + Reset $initial;$old=Key $script:policy;$r=Configure + Assert ($r.ExitCode -eq 0 -and $r.Scope -ceq 'process-commandline-policy-only' -and $r.ReadyRuleCredit -eq 0) 'Public report scopes success to one policy, without detection credit.' + Assert ($script:policy.Value -eq 1 -and $script:writes -eq $(if($initial -eq 1){0}else{1})) 'Absent/disabled are enabled; existing enabled policy is idempotent.' + if($initial -ne 1){$j=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json);Assert ($j.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq $old) 'Typed original snapshot is durable before the one write.'} + else{Assert (-not(Test-Path (Join-Path $backup 'before.jsonl'))) 'Already configured mode does not journal a write.'} + } + foreach($value in @(2,42,'1')) { + Reset $value $(if($value -is [string]){'String'}else{'DWord'});$r=Configure + Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $r.Results[0].Status -ceq 'Failed') 'Unknown values/types remain untouched.' + } + Reset $null;$script:policy.KeyExists=$false;$r=Configure + Assert ($r.ExitCode -eq 0 -and $script:policy.KeyExists -and $script:writes -eq 1) 'Missing Audit key is created without replacing the parent.' + Reset 0;$r=Configure -DryRun;Assert ($script:writes -eq 0 -and $r.DryRun -and -not(Test-Path $backup)) 'Dry run has no policy or journal-directory mutation.' + Reset 0;$script:failRead=$true;$r=Configure;Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0) 'An unreadable policy fails closed.' + foreach($kind in @('failWrite','ignoreWrite')){ + Reset 0;Set-Variable -Scope Script -Name $kind -Value $true;$r=Configure + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed') 'Native failure and ignored-write readback cannot report success.' + } + foreach($changed in @(1,2,42)){ + Reset 0;$script:changed=$changed;$script:promptChange={$script:policy.Value=$script:changed};$r=Configure -Prompt + Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $script:policy.Value -eq $changed) 'Prompt-time drift refuses writes after preserving the exact original receipt.' + } + Reset 0;$script:onRead={if($script:reads -eq 5){$script:policy.Value=0}};$r=Configure + Assert ($script:writes -eq 1 -and $r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Overridden') 'A later policy change fails final verification.' + Reset 0;$script:promptChange={$script:unselected.Values=@('new sibling')};$r=Configure -Prompt;Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0) 'Unrelated policy drift refuses mutation.' + Reset 0;$r=Invoke-WelaProcessCommandline -Action Plan;Assert ($r.Plan.Status -ceq 'ChangeRequired' -and $script:writes -eq 0) 'Plan is current-host assessment and does not mutate policy.' + foreach($action in @('Audit','Plan')){foreach($option in @('Auto','DryRun','BackupPath')){ + $a=@{Action=$action};$a[$option]=$(if($option -eq 'BackupPath'){'unused'}else{$true});$threw=$false;try{Invoke-WelaProcessCommandline @a}catch{$threw=$true};Assert $threw 'Read-only actions reject mutation-only options.' + }} +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count scoped process command-line assertions." +exit 0 diff --git a/tests/ProcessCommandline.Windows.Tests.ps1 b/tests/ProcessCommandline.Windows.Tests.ps1 new file mode 100644 index 00000000..c37e93cd --- /dev/null +++ b/tests/ProcessCommandline.Windows.Tests.ps1 @@ -0,0 +1,115 @@ +param([switch]$AllowDisposableAuditWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableAuditWrite -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.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/ProcessCommandline.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +. (Join-Path $repo 'scripts/ControlApplicability.ps1') +. (Join-Path $repo 'scripts/NativeValidation.ps1') +$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$errors=@() +$root=Join-Path $env:RUNNER_TEMP ('wela-process-commandline-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit';$name='ProcessCreationIncludeCmdLine_Enabled' +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +function Other { + [pscustomobject][ordered]@{Unselected=(Get-WelaProcessCommandlineSnapshot).Unselected;SecurityChannel=Get-WelaNativeChannel Security;Services=@(Get-Service Winmgmt,EventLog,WinRM,Wecsvc|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;State=[string]$_.Status}})} +} +Add-Type -TypeDefinition @' +using System;using System.IO;using System.Text;using System.Threading.Tasks; +public static class WelaCommandlineFixturePipe { + public static async Task Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeds one Mi character bound.");text.Append(buffer,0,n);}} +} +'@ +function Public([string]$Label,[string[]]$Arguments){ + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $repo 'WELA.ps1'),'process-commandline')+$Arguments + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try{ + if(-not $process.Start()){throw 'Public process did not start.'};$started=$true + $stdout=[WelaCommandlineFixturePipe]::Read($process.StandardOutput);$stderr=[WelaCommandlineFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'} + $output=$stdout.Result+"`n"+$stderr.Result + $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 + Assert ($process.ExitCode -eq 0) "Public $Label exited $($process.ExitCode) : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed.'}} + $process.Dispose() + } +} +$original=Get-WelaProcessCommandlineSnapshot +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy' +$precedence=Get-WelaRegistryState $precedencePath $precedenceName +$allMasks=Get-WelaEffectiveAuditPolicy;$masks=Masks;$guid='0cce922b-69ae-11d9-bed3-505054503030' +$other=Other;$touched=$false;$nativeEvents=0 +Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server required.' +Assert (-not $original.Policy.ValueExists -or ($original.Policy.Type -ceq 'DWord' -and $original.Policy.Value -in 0,1)) 'Unknown original values are preserved.' +Assert (-not $precedence.ValueExists -or ($precedence.Type -ceq 'DWord' -and $precedence.Value -in 0,1)) 'Unknown original precedence is preserved.' +Save 'original.json' @{Snapshot=$original;Unselected=$other;Masks=$masks;Precedence=$precedence;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA;Sources=@(foreach($file in @('WELA.ps1','scripts/ProcessCommandline.ps1','scripts/Configuration.ps1','scripts/NativeValidation.ps1','scripts/ControlApplicability.ps1','modules/AuditProfiles.psm1')){[pscustomobject]@{Name=$file;Sha256=(Get-FileHash (Join-Path $repo $file)).Hash.ToLowerInvariant()}})} +try { + # This owned fixture prepares only the independent prerequisites. The public command must preserve them. + $touched=$true + Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Value 1 -Type DWord -ErrorAction Stop + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum + $preparedMasks=Masks;$preparedPrecedence=Get-WelaRegistryState $precedencePath $precedenceName + foreach ($case in @('absent','disabled')) { + if ((Get-WelaRegistryState $path $name).ValueExists) {Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if ($case -eq 'disabled') {$null=New-WelaRegistryKey $path;Set-ItemProperty -LiteralPath $path -Name $name -Value 0 -Type DWord -ErrorAction Stop} + $prepared=Get-WelaProcessCommandlineSnapshot + $plan=Public ($case+'-plan') @('-ProcessCommandlineAction','Plan','-ResultsPath',(Join-Path $root ($case+'-plan.json'))) + Assert ($plan.Plan.Status -ceq 'ChangeRequired' -and (Key $plan.Plan.Before) -ceq (Key $prepared) -and $plan.Plan.Prerequisite.State -ceq 'SuccessEnabled') 'Public plan records exact typed state and separate success prerequisite.' + $dryBackup=Join-Path $root ($case+'-dry-backup') + $dry=Public ($case+'-dry') @('-ProcessCommandlineAction','Configure','-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $root ($case+'-dry.json'))) + Assert ($dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup) -and (Key (Get-WelaProcessCommandlineSnapshot)) -ceq (Key $prepared)) 'Dry run changes no registry state and creates no backup directory.' + $backup=Join-Path $root ($case+'-backup') + $report=Public $case @('-ProcessCommandlineAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',(Join-Path $root ($case+'.json'))) + $after=Get-WelaProcessCommandlineSnapshot + Assert ($report.Scope -ceq 'process-commandline-policy-only' -and $report.Results.Count -eq 1 -and $report.Results[0].Status -ceq 'Applied' -and $report.ReadyRuleCredit -eq 0) 'Public Configure applies exactly one policy with zero rule credit.' + Assert ($after.Policy.Type -ceq 'DWord' -and $after.Policy.Value -eq 1 -and (Key $report.Results[0].After) -ceq (Key $after)) 'Actual DWORD readback matches the public result.' + $journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 1 -and (Key $journal[0].Before) -ceq (Key $prepared)) 'Original journal retains exact typed prior state.' + $repeat=Public ($case+'-repeat') @('-ProcessCommandlineAction','Configure','-Auto','-BackupPath',(Join-Path $root ($case+'-repeat-backup')),'-ResultsPath',(Join-Path $root ($case+'-repeat.json'))) + Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant') 'Repeat is idempotent.' + Assert ((Masks) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq (Key $preparedPrecedence) -and (Key (Other)) -ceq (Key $other)) 'Public command preserves all59 masks, typed precedence, other values, Security channel and services.' + $destination=Join-Path $root ($case+'-4688') + $event=Invoke-WelaNativeValidation -Action Run -OutputPath $destination -TimeoutSeconds 30 + Save ($case+'-4688-report.json') $event + Assert ($event.ExitCode -eq 0 -and $event.Status -ceq 'NativeEventObserved' -and $event.ReadyRuleCredit -eq 0) 'The separate fixed native probe observes an actual attributed4688.' + $xml=[IO.File]::ReadAllText((Join-Path $destination 'event.xml')) + Assert (Test-WelaProbeEvent $xml $event.Process $event.BeforeState ([DateTime]::UtcNow)) 'Exact native process IDs, executable, command line, provider, host and interval match.' + foreach ($artifact in $event.Artifacts) {Assert ((Get-FileHash -LiteralPath (Join-Path $destination $artifact.path)).Hash.ToLowerInvariant() -ceq $artifact.sha256) 'Probe artifact hash matches.'} + $nativeEvents++ + } + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact + $missing=Public 'missing-prerequisite' @('-ProcessCommandlineAction','Audit','-ResultsPath',(Join-Path $root 'missing-prerequisite.json')) + Assert ($missing.Plan.Status -ceq 'AlreadyCompliant' -and $missing.Plan.Prerequisite.State -ceq 'SuccessMissing' -and $missing.ReadyRuleCredit -eq 0) 'An enabled DWORD never hides the separate missing Process Creation prerequisite.' + Save 'completed.json' @{Status='Passed';Assertions=$count;NativeWrites=2;Exact4688=$nativeEvents;Scope='Actual standalone Server only; no Windows11/DC/CA, forwarding, backend or complete-rule credit.'} +} catch {$failure=$_.ToString();throw} finally { + if ($touched) { + foreach ($c in @(@($path,$name,$original.Policy),@($precedencePath,$precedenceName,$precedence))) { + try { + if ((Get-WelaRegistryState $c[0] $c[1]).ValueExists) {Remove-ItemProperty -LiteralPath $c[0] -Name $c[1] -ErrorAction Stop} + if ($c[2].ValueExists) {$null=New-ItemProperty -LiteralPath $c[0] -Name $c[1] -Value $c[2].Value -PropertyType $c[2].Type -ErrorAction Stop} + if (-not $c[2].KeyExists -and (Test-Path -LiteralPath $c[0])) { + $k=Get-Item -LiteralPath $c[0];if($k.ValueCount -or $k.SubKeyCount){throw 'New policy key contains unrelated data; refusing deletion.'} + Remove-Item -LiteralPath $c[0] -ErrorAction Stop + } + } catch {$errors+=$_.ToString()} + } + try {Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $allMasks[$guid] -Mode exact}catch{$errors+=$_.ToString()} + } + $checks=[ordered]@{} + foreach($pair in @(@('Policy',{(Key (Get-WelaProcessCommandlineSnapshot)) -ceq (Key $original)}),@('Unselected',{(Key (Other)) -ceq (Key $other)}),@('All59Masks',{(Masks) -ceq $masks}),@('Precedence',{(Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq (Key $precedence)}))) {try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}} + $complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count} + Save 'artifact-hashes.json' @(Get-ChildItem -LiteralPath $root -Recurse -File|Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object{[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName).Hash.ToLowerInvariant()}}) + if(-not $complete){throw 'Process command-line native fixture cleanup failed.'} +} +Write-Host "PASS: $count native command-line assertions, $nativeEvents exact4688 records and exact cleanup." +exit 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4634748f..1c448c9f 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 組み込みの Security 4688 コマンドライン記録ポリシーのみを扱う `process-commandline` の Audit/Plan/Configure を追加しました。変更前の型付き状態、競合検出、監査の前提条件の分離、Windows 上の設定とイベント検証に対応します。#364、#365、#387 に関連します。 - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index cb0153b1..cd20932a 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added `process-commandline` Audit/Plan/Configure for only the built-in Security 4688 command-line policy, with typed originals, drift checks, separate audit prerequisites and native Windows configuration/event validation. Related to #364, #365 and #387. - Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security) From 3f613ea19c7e3836e71c8392d1b13f6dd9524131 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:37:38 +0900 Subject: [PATCH 2/9] Add scoped Windows PowerShell logging policy and native validation --- .github/workflows/powershell-logging.yml | 46 +++++++ WELA.ps1 | 20 ++- scripts/Configuration.ps1 | 2 +- scripts/PowerShellLogging.ps1 | 157 ++++++++++++++++++++++ tests/PowerShellLogging.Cli.Tests.ps1 | 19 +++ tests/PowerShellLogging.Tests.ps1 | 78 +++++++++++ tests/PowerShellLogging.Windows.Tests.ps1 | 140 +++++++++++++++++++ 7 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/powershell-logging.yml create mode 100644 scripts/PowerShellLogging.ps1 create mode 100644 tests/PowerShellLogging.Cli.Tests.ps1 create mode 100644 tests/PowerShellLogging.Tests.ps1 create mode 100644 tests/PowerShellLogging.Windows.Tests.ps1 diff --git a/.github/workflows/powershell-logging.yml b/.github/workflows/powershell-logging.yml new file mode 100644 index 00000000..9bba5b87 --- /dev/null +++ b/.github/workflows/powershell-logging.yml @@ -0,0 +1,46 @@ +name: Scoped Windows PowerShell event logging +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/PowerShellLogging.ps1' + - 'scripts/Configuration.ps1' + - 'tests/PowerShellLogging*' + - '.github/workflows/powershell-logging.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + powershell-logging: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Scoped policy and native events in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/PowerShellLogging.Tests.ps1 + ./tests/PowerShellLogging.Cli.Tests.ps1 + ./tests/PowerShellLogging.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Scoped policy and native events in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/PowerShellLogging.Tests.ps1 + ./tests/PowerShellLogging.Cli.Tests.ps1 + ./tests/PowerShellLogging.Windows.Tests.ps1 -AllowDisposableLoggingWrite + - name: Retain native policy, event and cleanup evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: powershell-logging-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-powershell-logging-*/ + if-no-files-found: error diff --git a/WELA.ps1 b/WELA.ps1 index 7ece9a25..d6b438fa 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -77,6 +77,9 @@ [string]$RuleEvidencePath, [string]$RuleCorpusPath, [string]$RuleManifestPath, + [ValidateSet('Audit','Plan','Configure')][string]$PowerShellLoggingAction = 'Audit', + [ValidateSet('ScriptBlock','Module')][string[]]$PowerShellLoggingControl, + [string[]]$PowerShellLoggingModuleName, [ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit', [string]$TranscriptDirectory, [ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit', @@ -266,6 +269,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1") . (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") +. (Join-Path $ScriptRoot "scripts/PowerShellLogging.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop @@ -2087,6 +2091,7 @@ Usage: ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation + ./WELA.ps1 powershell-logging -Help # Configure selected Windows PowerShell event policies ./WELA.ps1 ntlm-auditing -Help # Configure selected incoming/domain NTLM auditing ./WELA.ps1 outgoing-ntlm -Help # Configure outgoing NTLM auditing independently ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription @@ -2198,6 +2203,12 @@ if ($Cmd -eq 'ntlm-auditing') { if ($NtlmAuditAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAuditAction Configure.'} if ($NtlmAuditAction -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('NtlmAuditScope')) {throw 'Configure requires explicit NtlmAuditScope Incoming, Domain or Both.'} } +if ($Cmd -ne 'powershell-logging' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'PowerShellLogging*'}).Count) {throw 'PowerShellLogging options require powershell-logging.'} +if ($Cmd -eq 'powershell-logging') { + if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','PowerShellLoggingAction','PowerShellLoggingControl','PowerShellLoggingModuleName','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'powershell-logging accepts only its dedicated options.'} + if (-not $Help) {Assert-WelaPsLoggingSelection $PowerShellLoggingAction $PowerShellLoggingControl $PowerShellLoggingModuleName} + if ($PowerShellLoggingAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require PowerShellLoggingAction Configure.'} +} if ($Cmd -ne 'outgoing-ntlm' -and $PSBoundParameters.ContainsKey('NtlmAction')) {throw 'NtlmAction requires outgoing-ntlm.'} if ($Cmd -eq 'outgoing-ntlm') { if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAction','OutgoingNtlmMode','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'outgoing-ntlm accepts only its dedicated options.'} @@ -2294,7 +2305,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 'powershell-logging' -and $PowerShellLoggingAction -eq 'Configure') -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 @@ -2745,6 +2756,13 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 } } + 'powershell-logging' { + if ($Help) {Write-Host 'Usage: powershell-logging [-PowerShellLoggingAction Audit|Plan|Configure] [-PowerShellLoggingControl ScriptBlock,Module] [-PowerShellLoggingModuleName literal-name,...] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath new.json]. Plan/Configure require explicit controls; Module requires explicit names. Target is Windows PowerShell 5.1; PowerShell Core settings and invocation logging are preserved. See docs/powershell-logging.md.';return} + if ($PowerShellLoggingAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'PowerShell logging configuration requires Administrator privileges.'} + $report=Invoke-WelaPowerShellLogging -Action $PowerShellLoggingAction -Control $PowerShellLoggingControl -ModuleName $PowerShellLoggingModuleName -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if($report.ExitCode){exit $report.ExitCode} + } 'powershell-transcription' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 powershell-transcription [-TranscriptionAction Audit|Plan|Configure] [-TranscriptDirectory absolute-existing-directory] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 5202f504..d419837d 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] + [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "windows-powershell-event-logging-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { diff --git a/scripts/PowerShellLogging.ps1 b/scripts/PowerShellLogging.ps1 new file mode 100644 index 00000000..e62c45b8 --- /dev/null +++ b/scripts/PowerShellLogging.ps1 @@ -0,0 +1,157 @@ +# Scoped machine policy for the built-in Windows PowerShell 5.1 engine. +function ConvertTo-WelaPsLoggingKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress } +function Assert-WelaPsLoggingSelection { + param([string]$Action,[string[]]$Control,[string[]]$ModuleName) + if ($null -eq $Control) {$Control=@()}; if ($null -eq $ModuleName) {$ModuleName=@()} + if ($Action -ne 'Audit' -and -not $Control.Count) { throw 'Plan and Configure require explicit PowerShellLoggingControl selection.' } + if (@($Control | ForEach-Object {$_.ToLowerInvariant()} | Select-Object -Unique).Count -ne $Control.Count -or @($Control | Where-Object {$_ -notin @('ScriptBlock','Module')}).Count) { throw 'Select unique ScriptBlock and/or Module controls.' } + if ($ModuleName.Count -and $Control -notcontains 'Module') { throw 'Module names require explicit Module selection.' } + if ($Action -ne 'Audit' -and $Control -contains 'Module' -and -not $ModuleName.Count) { throw 'Module selection requires explicit PowerShellLoggingModuleName values; use * only after reviewing all-module scope.' } + if ($ModuleName.Count -gt 32 -or @($ModuleName | ForEach-Object {$_.ToLowerInvariant()} | Select-Object -Unique).Count -ne $ModuleName.Count) { throw 'Select at most 32 unique module names.' } + foreach ($name in $ModuleName) { + if ($name -cne '*' -and $name -cnotmatch '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') { throw 'Use literal module names or the explicitly selected * all-module value; paths and other wildcard patterns are refused.' } + } +} +function Get-WelaPsLoggingTree { + param([ValidateSet('LocalMachine','CurrentUser')][string]$Hive,[ValidateSet('Registry64','Registry32')][string]$View,[string]$Root) + $base=$null;$rows=New-Object 'System.Collections.Generic.List[object]';$queue=New-Object 'System.Collections.Generic.Queue[string]';$queue.Enqueue('') + try { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$Hive,[Microsoft.Win32.RegistryView]::$View) + while ($queue.Count) { + if ($rows.Count -ge 64) { throw 'PowerShell policy tree exceeds 64 keys; no partial snapshot is accepted.' } + $relative=$queue.Dequeue();$path=$Root;if ($relative) {$path+='\'+$relative};$key=$null + try { + $key=$base.OpenSubKey($path,$false) + if (-not $key) { if ($relative) {throw 'Policy key disappeared during enumeration.'};return [pscustomobject]@{Exists=$false;Keys=@()} } + $names=@($key.GetValueNames()|Sort-Object);$children=@($key.GetSubKeyNames()|Sort-Object) + if ($names.Count -gt 128 -or $children.Count -gt 64) {throw 'PowerShell policy key inventory is too large.'} + $values=@(foreach ($name in $names) {[pscustomobject][ordered]@{Name=$name;Type=$key.GetValueKind($name).ToString();Value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}}) + $acl=if ($PSVersionTable.PSVersion.Major -ge 6) {[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($key)}else{$key.GetAccessControl()} + $rows.Add([pscustomobject][ordered]@{Path=$relative;Values=$values;Children=$children;Access=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]'Owner,Group,Access')}) + foreach ($child in $children) {$next=if($relative){$relative+'\'+$child}else{$child};if($next.Split('\').Count -gt 8){throw 'PowerShell policy tree exceeds eight levels.'};$queue.Enqueue($next)} + } finally {if($key){$key.Dispose()}} + } + $result=[pscustomobject]@{Exists=$true;Keys=@($rows.ToArray()|Sort-Object Path)} + if ((ConvertTo-WelaPsLoggingKey $result).Length -gt 1048576) {throw 'PowerShell policy snapshot exceeds one Mi character bound.'} + return $result + } finally {if($base){$base.Dispose()}} +} +function Get-WelaPsLoggingSources { + $root=Split-Path $PSScriptRoot -Parent + @(foreach ($name in @('WELA.ps1','scripts/PowerShellLogging.ps1','scripts/Configuration.ps1')) {[pscustomobject]@{Path=$name;Sha256=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256).Hash.ToLowerInvariant()}}) +} +function Get-WelaPsLoggingSnapshot { + if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'Use native 64-bit PowerShell on Windows.'} + foreach($service in @('Winmgmt','EventLog')) {if((Get-Service -Name $service -ErrorAction Stop).Status -ne 'Running'){throw "$service must already be running; no service is started."}} + $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -Property Name,Domain,DomainRole,PartOfDomain -ErrorAction Stop + if ([string]$os.BuildNumber -notmatch '^\d+$' -or $computer.PartOfDomain -isnot [bool] -or $computer.DomainRole -notin @(0,1,2,3,4,5)) {throw 'Complete actual Windows role/build/join context is required.'} + $build=[int]$os.BuildNumber;$product=[int]$os.ProductType;$role=[int]$computer.DomainRole;$joined=$computer.PartOfDomain + $coherent=($product -eq 1 -and (($role -eq 0 -and -not $joined) -or ($role -eq 1 -and $joined))) -or ($product -eq 3 -and (($role -eq 2 -and -not $joined) -or ($role -eq 3 -and $joined))) -or ($product -eq 2 -and $role -in @(4,5) -and $joined) + if (-not $coherent -or [string]::IsNullOrWhiteSpace($computer.Name)) {throw 'Native host role observations conflict.'} + if (-not (($product -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or ($product -in @(2,3) -and $build -in @(20348,26100)))) {throw 'Windows role/build is outside reviewed scope.'} + $engine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine' PowerShellVersion + if (-not $engine.ValueExists -or $engine.Type -cne 'String' -or $engine.Value -notmatch '^5\.1(?:\.|$)') {throw 'Installed Windows PowerShell 5.1 is not confirmed.'} + $exe=Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe' + $engineHash=(Get-FileHash -LiteralPath $exe -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if (-not (Get-WelaRegistryState 'HKLM:\SOFTWARE\Policies\Microsoft\Windows' '__WelaObservationOnly').KeyExists) {throw 'Existing Microsoft Windows policy parent key is required; unrecorded ancestors will not be created.'} + $windowsRoot='SOFTWARE\Policies\Microsoft\Windows\PowerShell';$coreRoot='SOFTWARE\Policies\Microsoft\PowerShellCore' + $machine=Get-WelaPsLoggingTree LocalMachine Registry64 $windowsRoot;$user=Get-WelaPsLoggingTree CurrentUser Registry64 $windowsRoot + if ((ConvertTo-WelaPsLoggingKey $machine) -cne (ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingTree LocalMachine Registry32 $windowsRoot)) -or (ConvertTo-WelaPsLoggingKey $user) -cne (ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingTree CurrentUser Registry32 $windowsRoot))) {throw 'Shared Windows PowerShell policy views disagree.'} + $coreMachine=Get-WelaPsLoggingTree LocalMachine Registry64 $coreRoot;$coreUser=Get-WelaPsLoggingTree CurrentUser Registry64 $coreRoot + $protected=Get-WelaPsLoggingTree LocalMachine Registry64 'SOFTWARE\Policies\Microsoft\Windows\EventLog\ProtectedEventLogging' + $channel=$null + try {$channel=Get-WinEvent -ListLog 'Microsoft-Windows-PowerShell/Operational' -ErrorAction Stop;$channelState=[pscustomobject]@{Name=[string]$channel.LogName;Enabled=[bool]$channel.IsEnabled;MaximumBytes=[long]$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Security=[string]$channel.SecurityDescriptor}}finally{if($channel -is [IDisposable]){$channel.Dispose()}} + $patch=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' UBR + if (-not $patch.ValueExists -or $patch.Type -ne 'DWord' -or $patch.Value -lt 0) {throw 'Exact native patch evidence is required.'} + [pscustomobject][ordered]@{Host=[pscustomobject]@{Computer=[string]$computer.Name;Domain=[string]$computer.Domain;Build=$build;Patch=$patch.Value;ProductType=$product;DomainRole=$role;PartOfDomain=$joined;CertSvcPresent=[bool](Get-Service CertSvc -ErrorAction SilentlyContinue)};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1';Version=$engine.Value;Path=$exe;Sha256=$engineHash;WelaHostVersion=$PSVersionTable.PSVersion.ToString()};Sources=@(Get-WelaPsLoggingSources);Machine=$machine;CurrentUser=$user;PowerShellCoreMachine=$coreMachine;PowerShellCoreUser=$coreUser;ProtectedEventLogging=$protected;Channel=$channelState} +} +function Get-WelaPsLoggingValue { + param($Tree,[string]$Path,[string]$Name) + $keys=@($Tree.Keys|Where-Object Path -ieq $Path) + if ($keys.Count -gt 1) {throw 'Ambiguous policy key.'} + if ($keys.Count -eq 1) {$rows=@($keys[0].Values|Where-Object Name -ieq $Name);if($rows.Count -gt 1){throw 'Ambiguous policy value.'};if($rows.Count){return $rows[0]}} + return $null +} +function Get-WelaPsLoggingDefinitions { + param([string[]]$Control,[string[]]$ModuleName) + # Add selected module entries before enabling module logging. No existing name is removed. + if ($Control -contains 'Module') { + foreach ($name in @($ModuleName|Sort-Object)) {[pscustomobject]@{Control='Module';Path='ModuleLogging\ModuleNames';Name=$name;Type='String';Value=$name}} + [pscustomobject]@{Control='Module';Path='ModuleLogging';Name='EnableModuleLogging';Type='DWord';Value=1} + } + if ($Control -contains 'ScriptBlock') {[pscustomobject]@{Control='ScriptBlock';Path='ScriptBlockLogging';Name='EnableScriptBlockLogging';Type='DWord';Value=1}} +} +function Test-WelaPsLoggingValue {param($Snapshot,$Definition) $value=Get-WelaPsLoggingValue $Snapshot.Machine $Definition.Path $Definition.Name;return $null -ne $value -and $value.Type -ceq $Definition.Type -and (ConvertTo-WelaPsLoggingKey $value.Value) -ceq (ConvertTo-WelaPsLoggingKey $Definition.Value)} +function Assert-WelaPsLoggingKnown { + param($Snapshot,[array]$Definitions) + foreach ($definition in $Definitions) { + $value=Get-WelaPsLoggingValue $Snapshot.Machine $definition.Path $definition.Name + if ($value -and ($value.Type -cne $definition.Type -or ($definition.Type -eq 'DWord' -and $value.Value -notin @(0,1)) -or ($definition.Type -eq 'String' -and $value.Value -cne $definition.Value))) {throw "Selected policy value has an unknown type/value or a name collision: $($definition.Path)/$($definition.Name)."} + } + if (@($Definitions|Where-Object Control -eq Module).Count) { + foreach($key in @($Snapshot.Machine.Keys|Where-Object Path -ieq 'ModuleLogging\ModuleNames')) {foreach($value in $key.Values) {if($value.Type -cne 'String' -or [string]::IsNullOrWhiteSpace($value.Value)){throw 'Existing module-name policy contains an unsupported type/empty value; preserve and review it.'}}} + } +} +function Set-WelaPsLoggingValue { + param($Definition) + if ($Definition.Path -notin @('ModuleLogging','ModuleLogging\ModuleNames','ScriptBlockLogging')) {throw 'Unsupported policy destination.'} + if (($Definition.Path -eq 'ModuleLogging' -and ($Definition.Name -cne 'EnableModuleLogging' -or $Definition.Type -cne 'DWord' -or $Definition.Value -ne 1)) -or ($Definition.Path -eq 'ScriptBlockLogging' -and ($Definition.Name -cne 'EnableScriptBlockLogging' -or $Definition.Type -cne 'DWord' -or $Definition.Value -ne 1))) {throw 'Unsupported logging DWORD mutation.'} + if ($Definition.Path -eq 'ModuleLogging\ModuleNames') {Assert-WelaPsLoggingSelection Configure @('Module') @($Definition.Name);if($Definition.Type -cne 'String' -or $Definition.Value -cne $Definition.Name){throw 'Unsupported module-name mutation.'}} + $base=$null;$key=$null + try {$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$Definition.Path);$key.SetValue($Definition.Name,$Definition.Value,[Microsoft.Win32.RegistryValueKind]::$($Definition.Type));$key.Flush()} + finally{if($key){$key.Dispose()};if($base){$base.Dispose()}} +} +function Assert-WelaPsLoggingTransition { + param($Before,$After,$Definition) + foreach($property in $Before.PSObject.Properties.Name|Where-Object {$_ -cne 'Machine'}) {if((ConvertTo-WelaPsLoggingKey $Before.$property) -cne (ConvertTo-WelaPsLoggingKey $After.$property)){throw "Unselected state changed: $property"}} + if (-not (Test-WelaPsLoggingValue $After $Definition)) {throw 'Selected policy value did not match native readback.'} + $allowed=@('');$parts=$Definition.Path.Split('\');$part='';foreach($segment in $parts){$part=if($part){$part+'\'+$segment}else{$segment};$allowed+=$part} + $old=@{};foreach($row in $Before.Machine.Keys){$old[$row.Path]=$row} + $new=@{};foreach($row in $After.Machine.Keys){$new[$row.Path]=$row} + foreach($path in $old.Keys){if(-not $new.ContainsKey($path)){throw 'An original policy key disappeared.'};if($new[$path].Access -cne $old[$path].Access){throw 'An existing policy key access descriptor changed.'}} + foreach($path in $new.Keys){ + if(-not $old.ContainsKey($path) -and $path -notin $allowed){throw 'An unrequested policy key appeared.'} + $expected=@();if($old.ContainsKey($path)){$expected=@($old[$path].Values|Where-Object {-not ($path -ieq $Definition.Path -and $_.Name -ieq $Definition.Name)})} + $observed=@($new[$path].Values|Where-Object {-not ($path -ieq $Definition.Path -and $_.Name -ieq $Definition.Name)}) + if((ConvertTo-WelaPsLoggingKey $expected) -cne (ConvertTo-WelaPsLoggingKey $observed)){throw 'Unrelated policy values changed.'} + $expectedChildren=@();if($old.ContainsKey($path)){$expectedChildren=@($old[$path].Children)} + foreach($possible in $allowed){if(-not $possible){continue};$separator=$possible.LastIndexOf('\');$parent=if($separator -ge 0){$possible.Substring(0,$separator)}else{''};$leaf=if($separator -ge 0){$possible.Substring($separator+1)}else{$possible};if($parent -ieq $path){$expectedChildren+= $leaf}} + if((ConvertTo-WelaPsLoggingKey @($expectedChildren|Sort-Object -Unique)) -cne (ConvertTo-WelaPsLoggingKey @($new[$path].Children|Sort-Object -Unique))){throw 'Unrelated policy subkeys changed.'} + } +} +function Invoke-WelaPowerShellLogging { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string[]]$Control=@(),[string[]]$ModuleName=@(),[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + Assert-WelaPsLoggingSelection $Action $Control $ModuleName + if($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)){throw 'Consent, dry-run and backup options require PowerShellLoggingAction Configure.'} + if($ResultsPath -and (Test-Path -LiteralPath $ResultsPath)){throw 'ResultsPath must name a new file.'} + $definitions=@(Get-WelaPsLoggingDefinitions $Control $ModuleName);$before=$null;$diagnostic='';$known=$false + try {$before=Get-WelaPsLoggingSnapshot;Assert-WelaPsLoggingKnown $before $definitions;$known=$true}catch{$diagnostic=$_.Exception.Message} + $plan=[pscustomobject]@{Selection=@($Control);ModuleNames=@($ModuleName);Before=$before;Controls=@(foreach($definition in $definitions){[pscustomobject]@{Definition=$definition;Status=$(if(-not $known){'Unknown'}elseif(Test-WelaPsLoggingValue $before $definition){'AlreadyCompliant'}else{'ChangeRequired'})}});Status=$(if($known){'Observed'}else{'Unknown'});Diagnostic=$diagnostic;Provenance=@('https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1','https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy');Meaning='Explicit Windows PowerShell 5.1 machine-policy selection. Existing module names remain active when Module logging is enabled; no claim of a complete Microsoft/CIS/ASD baseline.'} + if($Action -eq 'Configure') { + if(-not $known){$report=[pscustomobject]@{ExitCode=1;Scope='windows-powershell-event-logging-policy-only';Results=@();Diagnostic=$diagnostic}} + else { + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + $shared=@{Expected=$before;Definitions=$definitions;Failed=$false} + foreach($definition in $definitions){ + $state=@{Shared=$shared;Definition=$definition} + $read={param($s) if($s.Shared.Failed){throw 'An earlier operation failed; remaining operations are stopped.'};$snapshot=Get-WelaPsLoggingSnapshot;if((ConvertTo-WelaPsLoggingKey $snapshot) -cne (ConvertTo-WelaPsLoggingKey $s.Shared.Expected)){throw 'Policy, host, channel, engine or source changed from the reviewed state.'};Assert-WelaPsLoggingKnown $snapshot $s.Shared.Definitions;return $snapshot} + $test={param($snapshot,$s) Test-WelaPsLoggingValue $snapshot $s.Definition} + $apply={param($s) + try {$fresh=Get-WelaPsLoggingSnapshot;if((ConvertTo-WelaPsLoggingKey $fresh) -cne (ConvertTo-WelaPsLoggingKey $s.Shared.Expected)){throw 'Pre-write state drifted after journal/approval; no write attempted.'};Set-WelaPsLoggingValue $s.Definition;$after=Get-WelaPsLoggingSnapshot;Assert-WelaPsLoggingTransition $fresh $after $s.Definition;$s.Shared.Expected=$after;'Only the named Windows PowerShell policy value was changed and read back.'}catch{$s.Shared.Failed=$true;throw} + } + Invoke-WelaConfigurationControl -Context $context -Id ('PowerShellLogging/'+$definition.Path+'/'+$definition.Name) -Kind Registry -Target @{Hive='LocalMachine';View='Registry64';Path=('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$definition.Path);Name=$definition.Name} -Desired @{Type=$definition.Type;Value=$definition.Value} -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Enable the explicitly selected event-logging policy; existing module names are preserved.' + if($context.Results[$context.Results.Count-1].Status -eq 'Failed'){$shared.Failed=$true;break} + if($context.Results[$context.Results.Count-1].Status -eq 'Skipped' -and -not $DryRun){break} + } + $report=Complete-WelaConfiguration -Context $context -Scope 'windows-powershell-event-logging-policy-only' -SuccessMessage 'Selected local machine policy values verified; fresh-session events and policy persistence remain separate.' + } + }else{$report=[pscustomobject]@{ExitCode=$(if($known){0}else{1});Scope='windows-powershell-event-logging-policy-only'}} + $report|Add-Member NoteProperty Action $Action;$report|Add-Member NoteProperty Plan $plan + $report|Add-Member NoteProperty EventGeneration 'Unverified; run a separately reviewed new Windows PowerShell session and retain native XML.' + $report|Add-Member NoteProperty PowerShell7Sessions 'Not assessed. Separate PowerShell Core policy/configuration and Windows-policy fallback are preserved; fallback users can inherit changed Windows settings.' + $report|Add-Member NoteProperty PolicyAuthority 'Local registry observations only; GPO/MDM persistence and current winning authority are not established.' + $report|Add-Member NoteProperty ReadyRuleCredit 0 + if($ResultsPath){$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($report|ConvertTo-Json -Depth 28));$file=[IO.File]::Open($ResultsPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$file.Write($bytes,0,$bytes.Length)}finally{$file.Dispose()}} + return $report +} diff --git a/tests/PowerShellLogging.Cli.Tests.ps1 b/tests/PowerShellLogging.Cli.Tests.ps1 new file mode 100644 index 00000000..b5304391 --- /dev/null +++ b/tests/PowerShellLogging.Cli.Tests.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('powershell-logging','-Help');Code=0;Pattern='Windows PowerShell 5.1'}, + @{Args=@('configure','-PowerShellLoggingAction','Configure');Code=1;Pattern='require powershell-logging'}, + @{Args=@('audit','-PowerShellLoggingControl','ScriptBlock');Code=1;Pattern='require powershell-logging'}, + @{Args=@('powershell-logging','-PowerShellLoggingAction','Configure');Code=1;Pattern='explicit PowerShellLoggingControl'}, + @{Args=@('powershell-logging','-PowerShellLoggingAction','Plan');Code=1;Pattern='explicit PowerShellLoggingControl'}, + @{Args=@('powershell-logging','-PowerShellLoggingAction','Configure','-PowerShellLoggingControl','Module');Code=1;Pattern='PowerShellLoggingModuleName'}, + @{Args=@('powershell-logging','-PowerShellLoggingControl','ScriptBlock','-PowerShellLoggingModuleName','Microsoft.PowerShell.Utility');Code=1;Pattern='Module selection'}, + @{Args=@('powershell-logging','-Role','DomainController');Code=1;Pattern='dedicated options'}, + @{Args=@('powershell-logging','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'}, + @{Args=@('powershell-logging','-Auto');Code=1;Pattern='PowerShellLoggingAction'}, + @{Args=@('powershell-logging','-DryRun');Code=1;Pattern='PowerShellLoggingAction'}, + @{Args=@('powershell-logging','-PowerShellLoggingControl','Module','-PowerShellLoggingModuleName','Mod*');Code=1;Pattern='literal module'}, + @{Args=@('powershell-logging','-Help','-Typo');Code=1;Pattern='Unsupported trailing arguments'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++} +Write-Host "PASS: $count scoped PowerShell logging CLI guards." +exit 0 diff --git a/tests/PowerShellLogging.Tests.ps1 b/tests/PowerShellLogging.Tests.ps1 new file mode 100644 index 00000000..931bbfbb --- /dev/null +++ b/tests/PowerShellLogging.Tests.ps1 @@ -0,0 +1,78 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/PowerShellLogging.ps1') +$script:count=0;$script:ScriptRoot=$repo;$script:writes=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-pslogging-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +function Assert($Value,$Message){if(-not $Value){throw "FAIL: $Message"};$script:count++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected rejection $Pattern; got $message"} +function CloneFixture($Value){ConvertTo-WelaPsLoggingKey $Value|ConvertFrom-Json} +function Row($Path,$Values=@(),$Children=@()){[pscustomobject]@{Path=$Path;Values=@($Values);Children=@($Children);Access='original-acl'}} +function Fixture { + [pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='fixture';Build=20348};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1'};Sources=@('sha');Machine=[pscustomobject]@{Exists=$true;Keys=@((Row '' @() @('ModuleLogging','ScriptBlockLogging','Transcription')),(Row 'ModuleLogging' @([pscustomobject]@{Name='EnableModuleLogging';Type='DWord';Value=0}) @('ModuleNames')),(Row 'ModuleLogging\ModuleNames' @([pscustomobject]@{Name='existing';Type='String';Value='Existing.Module'})),(Row 'ScriptBlockLogging' @([pscustomobject]@{Name='EnableScriptBlockLogging';Type='DWord';Value=0},[pscustomobject]@{Name='EnableScriptBlockInvocationLogging';Type='DWord';Value=1})),(Row 'Transcription' @([pscustomobject]@{Name='EnableTranscripting';Type='DWord';Value=1})))};CurrentUser=@('preserved-user');PowerShellCoreMachine=@('preserved-core');PowerShellCoreUser=@('preserved-core-user');ProtectedEventLogging=@('preserved-protected');Channel=@('preserved-channel')} +} +function Mutate($Before,$Definition){ + $after=CloneFixture $Before;$after.Machine.Exists=$true + $allowed=@('');$p='';foreach($segment in $Definition.Path.Split('\')){$p=if($p){$p+'\'+$segment}else{$segment};$allowed+=$p} + foreach($path in $allowed){if(-not @($after.Machine.Keys|Where-Object Path -ieq $path).Count){$after.Machine.Keys+=Row $path}} + foreach($key in $after.Machine.Keys){$key.Children=@($after.Machine.Keys|Where-Object {$_.Path -and $(if($_.Path.Contains('\')){$_.Path.Substring(0,$_.Path.LastIndexOf('\'))}else{''}) -ceq $key.Path}|ForEach-Object {$_.Path.Split('\')[-1]}|Sort-Object)} + $row=@($after.Machine.Keys|Where-Object Path -ieq $Definition.Path)[0];$row.Values=@($row.Values|Where-Object Name -ine $Definition.Name)+[pscustomobject]@{Name=$Definition.Name;Type=$Definition.Type;Value=$Definition.Value};$row.Values=@($row.Values|Sort-Object Name);$after.Machine.Keys=@($after.Machine.Keys|Sort-Object Path);return $after +} +function Get-WelaPsLoggingSnapshot {CloneFixture $script:observed} +function Set-WelaPsLoggingValue {param($Definition)$script:writes++;if($script:failWrite){throw 'injected native write failure'};$script:observed=Mutate $script:observed $Definition;if($script:corrupt){$script:observed.CurrentUser=@('changed-user')}} +function Reset {$script:observed=Fixture;$script:writes=0;$script:failWrite=$false;$script:corrupt=$false} +try { + foreach($action in @('Plan','Configure')){Reject {Assert-WelaPsLoggingSelection $action @() @()} 'explicit'} + Reject {Assert-WelaPsLoggingSelection Configure @('Module') @()} 'ModuleName' + Reject {Assert-WelaPsLoggingSelection Plan @('ScriptBlock') @('x')} 'Module selection' + foreach($name in @('','C:\module','Mod*','../a','a?','a\b',('x'*129))){Reject {Assert-WelaPsLoggingSelection Plan @('Module') @($name)} 'literal'} + Reject {Assert-WelaPsLoggingSelection Plan @('Module','module') @('a')} 'unique' + Reject {Assert-WelaPsLoggingSelection Plan @('Module') @('A','a')} 'unique' + Assert-WelaPsLoggingSelection Plan @('Module') @('*');Assert $true 'Explicit all-module accepted' + Assert-WelaPsLoggingSelection Audit @() @();Assert $true 'Unselected Audit accepted' + $definitions=@(Get-WelaPsLoggingDefinitions @('Module','ScriptBlock') @('Microsoft.PowerShell.Utility')) + Assert ($definitions.Count -eq 3 -and $definitions[0].Type -eq 'String' -and $definitions[1].Name -eq 'EnableModuleLogging' -and $definitions[2].Name -eq 'EnableScriptBlockLogging') 'Name-before-enable ordering' + Reset;$before=CloneFixture $script:observed;$report=Invoke-WelaPowerShellLogging -Action Audit + Assert ($report.ExitCode -eq 0 -and $script:writes -eq 0 -and $report.ReadyRuleCredit -eq 0) 'Audit is read-only with zero readiness credit' + $plan=Invoke-WelaPowerShellLogging -Action Plan -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility + Assert ($plan.Plan.Controls.Count -eq 3 -and @($plan.Plan.Controls|Where-Object Status -eq ChangeRequired).Count -eq 3) 'Plan identifies all selected values' + $dry=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -DryRun -BackupPath (Join-Path $root 'dry') + Assert ($dry.ExitCode -eq 0 -and $dry.Skipped -eq 3 -and $script:writes -eq 0 -and -not (Test-Path (Join-Path $root 'dry'))) 'Dry run creates no journal or write' + $run=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'run') + Assert ($run.ExitCode -eq 0 -and $script:writes -eq 3 -and @($run.Results|Where-Object Status -eq Applied).Count -eq 3) 'Three exact writes applied' + $journal=@(Get-Content (Join-Path $root 'run/before.jsonl')|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($journal.Count -eq 3 -and (ConvertTo-WelaPsLoggingKey $journal[0].Before) -ceq (ConvertTo-WelaPsLoggingKey $before)) 'Complete original snapshot journaled before any mutation' + Assert ((Get-WelaPsLoggingValue $script:observed.Machine 'ModuleLogging\ModuleNames' 'existing').Value -ceq 'Existing.Module') 'Other module names retained' + Assert ((Get-WelaPsLoggingValue $script:observed.Machine 'ScriptBlockLogging' EnableScriptBlockInvocationLogging).Value -eq 1) 'Invocation logging retained' + Assert ((ConvertTo-WelaPsLoggingKey $script:observed.CurrentUser) -ceq (ConvertTo-WelaPsLoggingKey $before.CurrentUser)) 'User policy retained' + $again=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'again') + Assert ($again.ExitCode -eq 0 -and $script:writes -eq 3 -and @($again.Results|Where-Object Status -eq AlreadyCompliant).Count -eq 3) 'Repeat is idempotent' + Reset;$script:observed.Machine=[pscustomobject]@{Exists=$false;Keys=@()} + $absent=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'absent') + Assert ($absent.ExitCode -eq 0 -and $script:writes -eq 3) 'Absent root creates only declared ancestors' + foreach($case in @(@{Type='String';Value='0'},@{Type='DWord';Value=2})){ + Reset;$value=Get-WelaPsLoggingValue $script:observed.Machine 'ScriptBlockLogging' EnableScriptBlockLogging;$value.Type=$case.Type;$value.Value=$case.Value + $bad=Invoke-WelaPowerShellLogging -Action Configure -Control ScriptBlock -Auto -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N'))) + Assert ($bad.ExitCode -eq 1 -and $script:writes -eq 0) 'Wrong type/unknown DWORD refused before write' + } + Reset;$badName=Get-WelaPsLoggingValue $script:observed.Machine 'ModuleLogging\ModuleNames' existing;$badName.Type='DWord';$badName.Value=1 + $bad=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'badname') + Assert ($bad.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'badname'))) 'Unknown module value fails entire preflight' + Reset;$script:failWrite=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'writefailure') + Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Native failure stops later writes' + Reset;$script:corrupt=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'corrupt') + Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Preservation failure stops later writes' + Reset;$snapshot=CloneFixture $script:observed;$definition=$definitions[0];$after=Mutate $snapshot $definition;Assert-WelaPsLoggingTransition $snapshot $after $definition;Assert $true 'Exact additive transition accepted' + foreach($property in @('Host','Sources','CurrentUser','PowerShellCoreMachine','PowerShellCoreUser','ProtectedEventLogging','Channel')){$changed=CloneFixture $after;$changed.$property='drift';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unselected state'} + $changed=CloneFixture $after;$changed.Machine.Keys[0].Access='new-acl';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'descriptor' + $changed=CloneFixture $after;$changed.Machine.Keys=@($changed.Machine.Keys|Where-Object Path -ne Transcription);Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'disappeared' + $changed=CloneFixture $after;$changed.Machine.Keys+=Row 'extra';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'unrequested' + $changed=CloneFixture $after;(Get-WelaPsLoggingValue $changed.Machine 'Transcription' EnableTranscripting).Value=0;Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unrelated policy values' + $changed=CloneFixture $after;$changed.Machine.Keys[0].Children+= 'extra';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'subkeys' + Reset;$script:promptBefore=CloneFixture $script:observed + function Read-Host {param($Prompt)$script:observed.Host.Computer='changed-during-prompt';'Y'} + $drift=Invoke-WelaPowerShellLogging -Action Configure -Control ScriptBlock -BackupPath (Join-Path $root 'drift') + Assert ($drift.ExitCode -eq 1 -and $script:writes -eq 0) 'Prompt-time drift refused before mutation' + Remove-Item Function:\Read-Host + Reset;Set-Content -LiteralPath (Join-Path $root 'existing.json') -Value 'keep';Reject {Invoke-WelaPowerShellLogging -ResultsPath (Join-Path $root 'existing.json')} 'new file';Assert ((Get-Content -Raw (Join-Path $root 'existing.json')).Trim() -ceq 'keep') 'Existing report preserved' + foreach($options in @(@{Auto=$true},@{DryRun=$true},@{BackupPath='x'})){Reject {Invoke-WelaPowerShellLogging @options} 'require PowerShellLoggingAction Configure'} + Write-Host "PASS: $script:count scoped PowerShell logging assertions." +} finally {Remove-Item -LiteralPath $root -Recurse -Force} diff --git a/tests/PowerShellLogging.Windows.Tests.ps1 b/tests/PowerShellLogging.Windows.Tests.ps1 new file mode 100644 index 00000000..8c692fab --- /dev/null +++ b/tests/PowerShellLogging.Windows.Tests.ps1 @@ -0,0 +1,140 @@ +param([switch]$AllowDisposableLoggingWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableLoggingWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on disposable GitHub-hosted Windows is required.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/PowerShellLogging.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +$root=Join-Path $env:RUNNER_TEMP ('wela-powershell-logging-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$engine=(Get-Process -Id $PID).Path;$script:count=0;$failure=$null;$cleanupErrors=@();$original=$null;$prepared=$null;$masks=$null;$workerPath=$null +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Save($Name,$Value){$Value|ConvertTo-Json -Depth 28|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +Add-Type -TypeDefinition @' +using System;using System.IO;using System.Text;using System.Threading.Tasks;using System.Runtime.InteropServices; +public static class WelaPsLoggingFixture { + [DllImport("kernel32.dll")] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime Now(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + public static async Task Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Owned child output exceeds one Mi character bound.");text.Append(buffer,0,n);}} +} +'@ +function Child([string]$Label,[string]$Executable,[string[]]$Arguments){ + foreach($a in $Arguments){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture process argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$Executable;$info.Arguments=(@($Arguments|ForEach-Object{'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $p=[Diagnostics.Process]::new();$p.StartInfo=$info;$started=$false + try{ + $start=[WelaPsLoggingFixture]::Now();if(-not $p.Start()){throw 'Child did not start.'};$started=$true;$ownedId=$p.Id + $stdout=[WelaPsLoggingFixture]::Read($p.StandardOutput);$stderr=[WelaPsLoggingFixture]::Read($p.StandardError) + if(-not $p.WaitForExit(180000)){throw 'Owned child exceeded three minutes.'};$end=[WelaPsLoggingFixture]::Now() + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Owned child output drain timed out.'} + $receipt=[pscustomobject]@{Executable=$Executable;Arguments=$info.Arguments;Pid=$ownedId;StartedUtc=$start.ToString('o');ExitedUtc=$end.ToString('o');ExitCode=$p.ExitCode;Output=$stdout.Result;Error=$stderr.Result} + Save ($Label+'-process.json') $receipt;return $receipt + }finally{if($started -and -not $p.HasExited){$p.Kill();if(-not $p.WaitForExit(5000)){throw 'Owned child exit could not be confirmed; cleanup may be incomplete.'}};$p.Dispose()} +} +$wrapper=Join-Path $root 'public.ps1' +@' +param([string]$Repository,[string]$Request) +$ErrorActionPreference='Stop' +$data=Get-Content -LiteralPath $Request -Raw|ConvertFrom-Json;$options=@{} +foreach($property in $data.PSObject.Properties){$options[$property.Name]=$property.Value} +& (Join-Path $Repository 'WELA.ps1') @options +exit 0 +'@ | Set-Content -LiteralPath $wrapper -Encoding UTF8 +function Public([string]$Label,[hashtable]$Parameters,[int]$ExpectedExit=0){ + $Parameters.Cmd='powershell-logging';$Parameters.ResultsPath=Join-Path $root ($Label+'.json');$request=Join-Path $root ($Label+'-request.json');$Parameters|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $request -Encoding UTF8 + $process=Child $Label $engine @('-NoLogo','-NoProfile','-NonInteractive','-File',$wrapper,'-Repository',$repo,'-Request',$request) + Assert (($process.ExitCode -eq 0) -eq ($ExpectedExit -eq 0)) "Public $Label unexpected exit $($process.ExitCode): $($process.Output) $($process.Error)" + if(Test-Path -LiteralPath $Parameters.ResultsPath){return Get-Content -Raw -LiteralPath $Parameters.ResultsPath|ConvertFrom-Json};throw 'Public report missing.' +} +function RestoreValue($Tree,[string]$Path,[string]$Name){ + $originalValue=Get-WelaPsLoggingValue $Tree $Path $Name;$base=$null;$key=$null + try{$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$Path,$true);if($originalValue){if(-not $key){throw 'Original policy key disappeared.'};$value=$originalValue.Value;if($originalValue.Type -eq 'DWord'){$value=[int]$value};if($originalValue.Type -eq 'QWord'){$value=[long]$value};if($originalValue.Type -eq 'Binary'){$value=[byte[]]$value};if($originalValue.Type -eq 'MultiString'){$value=[string[]]$value};$key.SetValue($Name,$value,[Microsoft.Win32.RegistryValueKind]::$($originalValue.Type));$key.Flush()}elseif($key){$key.DeleteValue($Name,$false);$key.Flush()}} + finally{if($key){$key.Dispose()};if($base){$base.Dispose()}} +} +function RemoveCreatedKeys($Tree){ + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + try{foreach($path in @('ModuleLogging\ModuleNames','ScriptBlockLogging','ModuleLogging','')){ + if(@($Tree.Keys|Where-Object Path -ieq $path).Count){continue};$full='SOFTWARE\Policies\Microsoft\Windows\PowerShell';if($path){$full+='\'+$path};$key=$null + try{$key=$base.OpenSubKey($full,$false);if(-not $key){continue};if($key.ValueCount -or $key.SubKeyCount){throw "Created key is no longer empty: $full"}}finally{if($key){$key.Dispose()}} + $base.DeleteSubKey($full,$false) + }}finally{$base.Dispose()} +} +function ReadEvents([int]$OwnedId,[long]$Watermark){ + $query="*[System[(EventID=4103 or EventID=4104) and Execution[@ProcessID='$OwnedId'] and EventRecordID > $Watermark]]" + $q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-PowerShell/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false + $reader=$null;$list=New-Object 'System.Collections.Generic.List[string]' + try{$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);for($i=0;$i -le 64;$i++){$record=$reader.ReadEvent([TimeSpan]::FromSeconds(2));if(-not $record){break};try{$xml=$record.ToXml();if($xml.Length -gt 262144){throw 'Owned child event exceeds XML bound.'};$list.Add($xml)}finally{$record.Dispose()};if($i -eq 64){throw 'Owned child event candidate cap exceeded.'}};foreach($status in $reader.LogStatus){if($status.StatusCode -ne 0){throw 'Native query reports an incomplete channel read.'}};return @($list.ToArray())}finally{if($reader){$reader.Dispose()}} +} +try{ + $original=Get-WelaPsLoggingSnapshot;Save 'original.json' $original;$masks=Masks + Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain -and -not $original.Host.CertSvcPresent) 'Fixture requires a standalone disposable non-CA server.' + Assert $original.Channel.Enabled 'Existing PowerShell operational channel must already be enabled.' + $protected=@($original.ProtectedEventLogging.Keys|ForEach-Object {$_.Values}|Where-Object {$_.Name -eq 'EnableProtectedEventLogging' -and $_.Value -ne 0}) + Assert ($protected.Count -eq 0) 'Protected logging must not obscure this plaintext event fixture.' + # Test fixture only: prepare explicit disabled values; production has no disable action. + foreach($pair in @(@('ModuleLogging','EnableModuleLogging'),@('ScriptBlockLogging','EnableScriptBlockLogging'))){ + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null + try{$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$pair[0]);$key.SetValue($pair[1],0,[Microsoft.Win32.RegistryValueKind]::DWord);$key.Flush()}finally{if($key){$key.Dispose()};$base.Dispose()} + } + $selectedName=Get-WelaPsLoggingValue $original.Machine 'ModuleLogging\ModuleNames' 'Microsoft.PowerShell.Utility' + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null + try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging\ModuleNames',$true);if($key){$key.DeleteValue('Microsoft.PowerShell.Utility',$false);$key.Flush()}}finally{if($key){$key.Dispose()};$base.Dispose()} + $prepared=Get-WelaPsLoggingSnapshot;Save 'prepared.json' $prepared + $audit=Public audit @{};Assert ($audit.ExitCode -eq 0 -and $audit.ReadyRuleCredit -eq 0) 'Unselected public Audit is observational.' + $plan=Public plan @{PowerShellLoggingAction='Plan';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility')} + Assert (@($plan.Plan.Controls|Where-Object Status -eq ChangeRequired).Count -eq 3) 'Public plan contains three exact value changes.' + $dry=Public dry @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');DryRun=$true;BackupPath=(Join-Path $root 'dry-backup')} + Assert ($dry.Skipped -eq 3 -and -not (Test-Path (Join-Path $root 'dry-backup'))) 'Public dry run writes neither policy nor backup.' + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $prepared)) 'Audit/Plan/DryRun preserve the complete prepared snapshot.' + $apply=Public configure @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');Auto=$true;BackupPath=(Join-Path $root 'configure-backup')} + Assert ($apply.ExitCode -eq 0 -and @($apply.Results|Where-Object Status -eq Applied).Count -eq 3) 'Public Configure applies three named values.' + $journal=@(Get-Content -LiteralPath (Join-Path $root 'configure-backup/before.jsonl')|ForEach-Object{$_|ConvertFrom-Json});Assert ($journal.Count -eq 3) 'Each native write has its own original journal.' + Assert ((ConvertTo-WelaPsLoggingKey $journal[0].Before) -ceq (ConvertTo-WelaPsLoggingKey $prepared)) 'First journal matches exact typed prepared state.' + $configured=Get-WelaPsLoggingSnapshot;Save 'configured.json' $configured + $again=Public repeat @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('Module','ScriptBlock');PowerShellLoggingModuleName=@('Microsoft.PowerShell.Utility');Auto=$true;BackupPath=(Join-Path $root 'repeat-backup')} + Assert (@($again.Results|Where-Object Status -eq AlreadyCompliant).Count -eq 3 -and -not (Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Second Configure makes no native writes.' + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $configured)) 'Repeated configuration preserves complete observed state.' + $nonce='WELA_PS_LOG_'+[guid]::NewGuid().ToString('N');$workerPath=Join-Path $root ('worker-'+[guid]::NewGuid().ToString('N')+'.ps1') + $workerText="Microsoft.PowerShell.Utility\Write-Output -InputObject '$nonce'`r`n" + [IO.File]::WriteAllText($workerPath,$workerText,[Text.UTF8Encoding]::new($false));Save 'worker-source.json' @{Path=$workerPath;Sha256=(Get-FileHash $workerPath -Algorithm SHA256).Hash;Text=$workerText;Nonce=$nonce} + $latest=Get-WinEvent -LogName 'Microsoft-Windows-PowerShell/Operational' -MaxEvents 1 -ErrorAction Stop;try{$watermark=[long]$latest.RecordId}finally{$latest.Dispose()} + $process=Child 'event-worker' $configured.Engine.Path @('-NoLogo','-NoProfile','-NonInteractive','-File',$workerPath) + Assert ($process.ExitCode -eq 0 -and $process.Output.Trim() -ceq $nonce) 'Fixed native Windows PowerShell child executed the benign marker.' + $selected=@{};$candidates=@();$timer=[Diagnostics.Stopwatch]::StartNew() + do{ + $candidates=@(ReadEvents $process.Pid $watermark);$selected=@{} + foreach($xml in $candidates){ + $doc=[xml]$xml;$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$system=$doc.SelectSingleNode('/e:Event/e:System',$ns);$data=@{} + foreach($node in $doc.SelectNodes('/e:Event/e:EventData/e:Data',$ns)){if($data.ContainsKey($node.GetAttribute('Name'))){throw 'Duplicate native event field.'};$data[$node.GetAttribute('Name')]=$node.InnerText} + $id=[int]$system.SelectSingleNode('e:EventID',$ns).InnerText + if($system.SelectSingleNode('e:Provider',$ns).GetAttribute('Name') -cne 'Microsoft-Windows-PowerShell' -or $system.SelectSingleNode('e:Provider',$ns).GetAttribute('Guid').Trim('{}') -ine 'a0c1853b-5c40-4b15-8766-3cf1c58f985a' -or [int]$system.SelectSingleNode('e:Execution',$ns).GetAttribute('ProcessID') -ne $process.Pid -or $system.SelectSingleNode('e:Channel',$ns).InnerText -cne 'Microsoft-Windows-PowerShell/Operational' -or $system.SelectSingleNode('e:Computer',$ns).InnerText -ine $env:COMPUTERNAME){continue} + $time=[DateTimeOffset]::Parse($system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime')).UtcDateTime;if($time -lt [DateTimeOffset]::Parse($process.StartedUtc).UtcDateTime -or $time -gt [DateTimeOffset]::Parse($process.ExitedUtc).UtcDateTime){continue} + if($system.SelectSingleNode('e:Security',$ns).GetAttribute('UserID') -cne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value){continue} + if($id -eq 4104 -and $data.ScriptBlockText -ceq $workerText -and $data.Path -ieq $workerPath -and $data.MessageNumber -ceq '1' -and $data.MessageTotal -ceq '1' -and $data.ScriptBlockId -match '^[0-9a-f-]{36}$'){$selected['4104']=@($selected['4104'])+ $xml} + if($id -eq 4103 -and $data.ContainsKey('Payload') -and $data.ContainsKey('ContextInfo') -and $data.Payload.Contains($nonce) -and $data.ContextInfo.Contains($workerPath)){$selected['4103']=@($selected['4103'])+ $xml} + } + if(@($selected['4103']|Where-Object {$_}).Count -eq 1 -and @($selected['4104']|Where-Object {$_}).Count -eq 1){break};Start-Sleep -Milliseconds 200 + }while($timer.Elapsed.TotalSeconds -lt 15) + Save 'event-candidates.json' $candidates + foreach($id in @('4103','4104')){$events=@($selected[$id]|Where-Object {$_});Assert ($events.Count -eq 1) "Exactly one worker-attributed native $id event required; found $($events.Count).";[IO.File]::WriteAllText((Join-Path $root ('event-'+$id+'.xml')),$events[0],[Text.UTF8Encoding]::new($false))} + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $configured)) 'Fresh event probe changes no policy or channel state.' + Assert ((Masks) -ceq $masks) 'All 59 audit masks remain unchanged.' + # Wrong-type selected value is refused by the public command without repairing it. + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null + try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging',$true);$key.SetValue('EnableScriptBlockLogging','1',[Microsoft.Win32.RegistryValueKind]::String)}finally{if($key){$key.Dispose()};$base.Dispose()} + $wrong=Get-WelaPsLoggingSnapshot;$refused=Public wrong-type @{PowerShellLoggingAction='Configure';PowerShellLoggingControl=@('ScriptBlock');Auto=$true;BackupPath=(Join-Path $root 'wrong-backup')} 1 + Assert ($refused.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'wrong-backup'))) 'Wrong-type preflight refuses before backup or writes.' + Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $wrong)) 'Refusal preserves the typed wrong value.' +}catch{$failure=$_.ToString();Save 'failure.json' @{Error=$failure;Stack=$_.ScriptStackTrace}} +finally{ + if($original){ + foreach($item in @(@('ScriptBlockLogging','EnableScriptBlockLogging'),@('ModuleLogging','EnableModuleLogging'),@('ModuleLogging\ModuleNames','Microsoft.PowerShell.Utility'))){try{RestoreValue $original.Machine $item[0] $item[1]}catch{$cleanupErrors+=$_.ToString()}} + try{RemoveCreatedKeys $original.Machine}catch{$cleanupErrors+=$_.ToString()} + try{$after=Get-WelaPsLoggingSnapshot;Save 'cleanup-after.json' $after;if((ConvertTo-WelaPsLoggingKey $after) -cne (ConvertTo-WelaPsLoggingKey $original)){$cleanupErrors+='Full policy/host/source/channel snapshot did not restore exactly.'};if($masks -and (Masks) -cne $masks){$cleanupErrors+='Audit masks changed.'}}catch{$cleanupErrors+=$_.ToString()} + } + Save 'cleanup.json' @{Status=$(if($cleanupErrors.Count){'Failed'}else{'Restored'});Errors=$cleanupErrors;OriginalCaptured=[bool]$original;All59MasksUnchanged=($masks -and (Masks) -ceq $masks)} + $artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object{[pscustomobject]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) + Save 'manifest.json' @{Kind='WelaPowerShellLoggingNativeFixture';Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$script:count;Failure=$failure;CleanupErrors=$cleanupErrors;ReadyRuleCredit=0;Artifacts=$artifacts} +} +if($failure -or $cleanupErrors.Count){throw "Native fixture failed: $failure Cleanup: $($cleanupErrors -join '; ')"} +Write-Host "PASS: $script:count public PowerShell policy/native4103/native4104 assertions with exact cleanup." From e419b073feff4a770b5341b2cccf11a7a44bb491 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:43:40 +0900 Subject: [PATCH 3/9] Document scoped PowerShell controls and align native evidence boundaries --- .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + docs/powershell-logging.md | 62 +++++++++++++++++++++++ scripts/PowerShellLogging.ps1 | 9 ++-- tests/PowerShellLogging.Tests.ps1 | 2 +- tests/PowerShellLogging.Windows.Tests.ps1 | 14 ++--- website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 9 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 docs/powershell-logging.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 544eba1a..0fbd08a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./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, ./docs/native-filesystem-sacl-validation.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/powershell-logging.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 764fd355..fcc8be1b 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security) + - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 195d6774..7b8a33ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security) + - Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security) diff --git a/docs/powershell-logging.md b/docs/powershell-logging.md new file mode 100644 index 00000000..0250e258 --- /dev/null +++ b/docs/powershell-logging.md @@ -0,0 +1,62 @@ +# Scoped Windows PowerShell event logging + +`powershell-logging` audits, plans and explicitly enables selected **Windows PowerShell 5.1** module or script-block logging. This is separate from [text transcription](powershell-transcription.md), advanced Security audit profiles, 4688 command-line capture and the broader `configure` command. It changes no channel, execution policy, service, invocation-logging preference, audit mask or transcript setting. Sysmon is excluded. + +```powershell +# Read-only inventory of machine/current-user Windows PowerShell policy, +# PowerShell Core policy, protected-event policy and the Operational channel. +./WELA.ps1 powershell-logging -ResultsPath new-audit.json + +# Select exactly the requested controls and module names. +./WELA.ps1 powershell-logging -PowerShellLoggingAction Plan ` + -PowerShellLoggingControl Module,ScriptBlock ` + -PowerShellLoggingModuleName Microsoft.PowerShell.Utility -ResultsPath new-plan.json +./WELA.ps1 powershell-logging -PowerShellLoggingAction Configure ` + -PowerShellLoggingControl Module,ScriptBlock ` + -PowerShellLoggingModuleName Microsoft.PowerShell.Utility -DryRun + +# After reviewing existing module names and the before-state: +./WELA.ps1 powershell-logging -PowerShellLoggingAction Configure ` + -PowerShellLoggingControl Module,ScriptBlock ` + -PowerShellLoggingModuleName Microsoft.PowerShell.Utility ` + -Auto -BackupPath C:\WELA-Recovery\new-run -ResultsPath new-result.json +``` + +Use a native 64-bit Windows PowerShell 5.1 or PowerShell 7 host. The actual Windows role, build, patch and installed Windows PowerShell engine are read; caller role/build overrides are refused. Reviewed families are Windows 11 builds 22000, 22621, 22631, 26100 and 26200, and Server 2022/2025 builds 20348/26100. Native client, joined-member, DC and AD CS acceptance remains separate from hosted standalone-server tests. `Winmgmt` and `EventLog` must already run; the command starts no service. Configure requires elevation and a fresh recovery directory. Plan and Configure require explicit controls; there is no implicit enable-everything selection. Audit without selection inventories existing policy only. + +The array examples are PowerShell syntax. When launching `powershell.exe -File` from another shell, use a reviewed PowerShell wrapper to bind multiple array elements correctly. Literal module names can contain ASCII letters, digits, dots, underscores and hyphens, up to 128 characters each. Up to 32 unique names can be selected. Paths and wildcard patterns are refused; the exact `*` value is accepted **only when explicitly supplied** to request all modules. Installation and execution of arbitrary selected modules are not performed or asserted. + +## Requested values and preserved scope + +Under `HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell`: + +| Selection | Requested values | +|---|---| +| `ScriptBlock` | `ScriptBlockLogging\EnableScriptBlockLogging` = DWORD 1 | +| `Module` | An explicit REG_SZ entry under `ModuleLogging\ModuleNames` for each selected module (name and data both equal the selected literal), then `ModuleLogging\EnableModuleLogging` = DWORD 1 | + +An existing matching module entry is retained. The command does not delete or replace other module names: **enabling Module logging also activates the existing configured module list**, which can already include `*` or broader patterns. Review the entire `Plan.Before.Machine` tree, not just the newly selected names. A collision between a selected value name and different existing data is refused. Unknown DWORD values, incorrect selected types, and non-string/empty existing module entries block the complete preflight. Unselected existing values and key access descriptors are preserved. Missing selected keys may be created below the existing Microsoft Windows policy parent; their absence is recorded in the original snapshot. + +Microsoft documents machine policy precedence over user policy, module pipeline logging, script-block logging, and the additional volume generated by invocation start/stop logging in [Windows PowerShell Group Policy settings](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1). The [ADMX mapping](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy#enablemodulelogging) documents the ModuleLogging registry location. This command offers explicit source controls; it does not import a Microsoft/CIS/ASD baseline or claim complete compliance with one. + +`SOFTWARE\Policies` is [shared across registry views](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys). Both views are compared before a native-view write; no literal `Wow6432Node` policy tree is created. Snapshots include the Windows PowerShell machine and current-user policy trees, each bounded to 64 keys, eight levels, 128 values per key and one Mi character serialized data. Incomplete, denied, unstable or excessive trees are refused. Existing owner/group/DACL observations are recorded; these snapshots do not claim registry SACL enumeration or effective access for other principals. + +PowerShell 7 has [separate PowerShell Core settings and an optional Windows-policy fallback](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-7.5). Its registry policy trees are observed and preserved; `powershell.config.json`, session behavior and fallback selection are not assessed. **A PowerShell 7 deployment using that fallback may inherit changes to Windows PowerShell policy.** Running WELA under PowerShell 7 does not establish PowerShell 7 event coverage. Existing sessions are not restarted or asserted to adopt the changes. + +Script-block and module event content can include command arguments and script text. Existing protected-event settings are observed and preserved; the command does not provision encryption certificates, decrypt events, change event-reader permissions or assess whether a collector can read the resulting content. + +## Journals, drift and recovery + +Every changed value has an original `before.jsonl` entry containing the full typed observed state, requested value and source fingerprints before its native setter runs. Module entries are added before the enable DWORD. The shared configuration runner handles DryRun, declined changes, immediate readback and final verification. The command compares host/engine/source hashes, channel metadata and all observed policy trees again after approval and journaling, then validates that only the one requested value and required new ancestor keys changed. Failed reads, partial writes, wrong readback or unrelated drift stop later operations. Available original/results evidence remains; no automatic rollback overwrites later policy. + +This is not an atomic registry transaction. Another administrator or GPO can change state between observations. Local registry compliance does not identify the current authoritative GPO/MDM source, prove persistence after refresh or imply provider event generation. `-Auto` skips ordinary per-value prompts only. No group-policy refresh is invoked. A skipped/dry-run result is not configuration evidence; failures produce a nonzero exit. Each explicit ResultsPath must be a new file and is created without overwrite; protect the recovery parent and resulting host/policy evidence. + +For manual recovery, compare the original journal, confirmed results and current state. Restore only each proven changed value, using its original registry type/data or removing that exact value when it was absent. Preserve other module names and all unrelated values. Remove a newly created key only when it was originally absent and remains empty. Never delete the whole PowerShell policy tree or restore old full descriptors. Determine whether a newer authoritative policy has superseded the recorded state before restoration. + +## Native validation and limits + +The focused suite covers explicit selection, exact types, module-name collisions, preservation, journal-before-write, missing-key creation, idempotence, dry runs, refused output reuse, partial failures and approval-time drift. Public CLI tests reject unrelated/profile/role options before dispatch. + +The opt-in disposable Server 2022/2025 matrix runs WELA under Windows PowerShell 5.1 and PowerShell 7. It saves native state, prepares selected disabled values, exercises the public Audit/Plan/DryRun/Configure path, checks original journals and idempotence, then launches a new fixed **native Windows PowerShell 5.1** utility command with a unique benign marker. Native Operational event XML must match the owned child PID, provider GUID/name, actual SID, computer, record boundary and measured process-lifetime interval; script-block evidence additionally matches the exact script path/text and complete fragment counts. Only the fixture prepares policy or generates events. Wrong-type refusal and exact typed policy/key, channel and all-59-mask cleanup are required. Native event artifacts must be reviewed before claiming a matrix run passed. + +These are configuration and local benign-event checks, not a Sigma rule match. Windows 11, managed clients, DC/CA hosts, x86 Windows PowerShell, PowerShell 7 event generation, forwarding, backend normalization/queries, retention and volume remain separate acceptance. Reports retain `ReadyRuleCredit=0`; issues #364, #366 and #387 remain open for their broader requirements. diff --git a/scripts/PowerShellLogging.ps1 b/scripts/PowerShellLogging.ps1 index e62c45b8..e16e33e9 100644 --- a/scripts/PowerShellLogging.ps1 +++ b/scripts/PowerShellLogging.ps1 @@ -124,7 +124,7 @@ function Invoke-WelaPowerShellLogging { param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string[]]$Control=@(),[string[]]$ModuleName=@(),[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) Assert-WelaPsLoggingSelection $Action $Control $ModuleName if($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)){throw 'Consent, dry-run and backup options require PowerShellLoggingAction Configure.'} - if($ResultsPath -and (Test-Path -LiteralPath $ResultsPath)){throw 'ResultsPath must name a new file.'} + if($ResultsPath){$ResultsPath=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ResultsPath);if(Test-Path -LiteralPath $ResultsPath){throw 'ResultsPath must name a new file.'};if(-not (Test-Path -LiteralPath ([IO.Path]::GetDirectoryName($ResultsPath)) -PathType Container)){throw 'ResultsPath parent must already exist.'}} $definitions=@(Get-WelaPsLoggingDefinitions $Control $ModuleName);$before=$null;$diagnostic='';$known=$false try {$before=Get-WelaPsLoggingSnapshot;Assert-WelaPsLoggingKnown $before $definitions;$known=$true}catch{$diagnostic=$_.Exception.Message} $plan=[pscustomobject]@{Selection=@($Control);ModuleNames=@($ModuleName);Before=$before;Controls=@(foreach($definition in $definitions){[pscustomobject]@{Definition=$definition;Status=$(if(-not $known){'Unknown'}elseif(Test-WelaPsLoggingValue $before $definition){'AlreadyCompliant'}else{'ChangeRequired'})}});Status=$(if($known){'Observed'}else{'Unknown'});Diagnostic=$diagnostic;Provenance=@('https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1','https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy');Meaning='Explicit Windows PowerShell 5.1 machine-policy selection. Existing module names remain active when Module logging is enabled; no claim of a complete Microsoft/CIS/ASD baseline.'} @@ -132,8 +132,9 @@ function Invoke-WelaPowerShellLogging { if(-not $known){$report=[pscustomobject]@{ExitCode=1;Scope='windows-powershell-event-logging-policy-only';Results=@();Diagnostic=$diagnostic}} else { $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath - $shared=@{Expected=$before;Definitions=$definitions;Failed=$false} + $shared=@{Expected=$before;Definitions=$definitions;Failed=$false;StopReason=$null} foreach($definition in $definitions){ + if($shared.StopReason){$context.Results.Add([pscustomobject]@{Id=('PowerShellLogging/'+$definition.Path+'/'+$definition.Name);Kind='Registry';Target=@{Path=$definition.Path;Name=$definition.Name};Desired=@{Type=$definition.Type;Value=$definition.Value};Before=$null;After=$null;Status='Skipped';Diagnostic=$shared.StopReason});continue} $state=@{Shared=$shared;Definition=$definition} $read={param($s) if($s.Shared.Failed){throw 'An earlier operation failed; remaining operations are stopped.'};$snapshot=Get-WelaPsLoggingSnapshot;if((ConvertTo-WelaPsLoggingKey $snapshot) -cne (ConvertTo-WelaPsLoggingKey $s.Shared.Expected)){throw 'Policy, host, channel, engine or source changed from the reviewed state.'};Assert-WelaPsLoggingKnown $snapshot $s.Shared.Definitions;return $snapshot} $test={param($snapshot,$s) Test-WelaPsLoggingValue $snapshot $s.Definition} @@ -141,8 +142,8 @@ function Invoke-WelaPowerShellLogging { try {$fresh=Get-WelaPsLoggingSnapshot;if((ConvertTo-WelaPsLoggingKey $fresh) -cne (ConvertTo-WelaPsLoggingKey $s.Shared.Expected)){throw 'Pre-write state drifted after journal/approval; no write attempted.'};Set-WelaPsLoggingValue $s.Definition;$after=Get-WelaPsLoggingSnapshot;Assert-WelaPsLoggingTransition $fresh $after $s.Definition;$s.Shared.Expected=$after;'Only the named Windows PowerShell policy value was changed and read back.'}catch{$s.Shared.Failed=$true;throw} } Invoke-WelaConfigurationControl -Context $context -Id ('PowerShellLogging/'+$definition.Path+'/'+$definition.Name) -Kind Registry -Target @{Hive='LocalMachine';View='Registry64';Path=('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$definition.Path);Name=$definition.Name} -Desired @{Type=$definition.Type;Value=$definition.Value} -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Enable the explicitly selected event-logging policy; existing module names are preserved.' - if($context.Results[$context.Results.Count-1].Status -eq 'Failed'){$shared.Failed=$true;break} - if($context.Results[$context.Results.Count-1].Status -eq 'Skipped' -and -not $DryRun){break} + if($context.Results[$context.Results.Count-1].Status -eq 'Failed'){$shared.Failed=$true;$shared.StopReason='Not attempted because an earlier selected operation failed.'} + if($context.Results[$context.Results.Count-1].Status -eq 'Skipped' -and -not $DryRun){$shared.StopReason='Not attempted because an earlier selected operation was declined.'} } $report=Complete-WelaConfiguration -Context $context -Scope 'windows-powershell-event-logging-policy-only' -SuccessMessage 'Selected local machine policy values verified; fresh-session events and policy persistence remain separate.' } diff --git a/tests/PowerShellLogging.Tests.ps1 b/tests/PowerShellLogging.Tests.ps1 index 931bbfbb..49c2804c 100644 --- a/tests/PowerShellLogging.Tests.ps1 +++ b/tests/PowerShellLogging.Tests.ps1 @@ -57,7 +57,7 @@ try { $bad=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'badname') Assert ($bad.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'badname'))) 'Unknown module value fails entire preflight' Reset;$script:failWrite=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'writefailure') - Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Native failure stops later writes' + Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1 -and @($failed.Results|Where-Object Status -eq Skipped).Count -eq 2) 'Native failure stops and explicitly reports later writes' Reset;$script:corrupt=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'corrupt') Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Preservation failure stops later writes' Reset;$snapshot=CloneFixture $script:observed;$definition=$definitions[0];$after=Mutate $snapshot $definition;Assert-WelaPsLoggingTransition $snapshot $after $definition;Assert $true 'Exact additive transition accepted' diff --git a/tests/PowerShellLogging.Windows.Tests.ps1 b/tests/PowerShellLogging.Windows.Tests.ps1 index 8c692fab..46e6fac9 100644 --- a/tests/PowerShellLogging.Windows.Tests.ps1 +++ b/tests/PowerShellLogging.Windows.Tests.ps1 @@ -6,7 +6,7 @@ $repo=Split-Path $PSScriptRoot -Parent . (Join-Path $repo 'scripts/PowerShellLogging.ps1') Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force $root=Join-Path $env:RUNNER_TEMP ('wela-powershell-logging-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root -$engine=(Get-Process -Id $PID).Path;$script:count=0;$failure=$null;$cleanupErrors=@();$original=$null;$prepared=$null;$masks=$null;$workerPath=$null +$engine=(Get-Process -Id $PID).Path;$script:count=0;$failure=$null;$cleanupErrors=@();$original=$null;$prepared=$null;$masks=$null;$workerPath=$null;$mutationStarted=$false function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} function Save($Name,$Value){$Value|ConvertTo-Json -Depth 28|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} @@ -63,7 +63,7 @@ function ReadEvents([int]$OwnedId,[long]$Watermark){ $query="*[System[(EventID=4103 or EventID=4104) and Execution[@ProcessID='$OwnedId'] and EventRecordID > $Watermark]]" $q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-PowerShell/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false $reader=$null;$list=New-Object 'System.Collections.Generic.List[string]' - try{$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);for($i=0;$i -le 64;$i++){$record=$reader.ReadEvent([TimeSpan]::FromSeconds(2));if(-not $record){break};try{$xml=$record.ToXml();if($xml.Length -gt 262144){throw 'Owned child event exceeds XML bound.'};$list.Add($xml)}finally{$record.Dispose()};if($i -eq 64){throw 'Owned child event candidate cap exceeded.'}};foreach($status in $reader.LogStatus){if($status.StatusCode -ne 0){throw 'Native query reports an incomplete channel read.'}};return @($list.ToArray())}finally{if($reader){$reader.Dispose()}} + try{$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);for($i=0;$i -le 64;$i++){$record=$reader.ReadEvent([TimeSpan]::FromSeconds(2));if(-not $record){break};try{$xml=$record.ToXml();if($xml.Length -gt 262144){throw 'Owned child event exceeds XML bound.'};$list.Add($xml)}finally{$record.Dispose()};if($i -eq 64){throw 'Owned child event candidate cap exceeded.'}};$statuses=@($reader.LogStatus);if($statuses.Count -ne 1 -or $statuses[0].LogName -cne 'Microsoft-Windows-PowerShell/Operational' -or $statuses[0].StatusCode -ne 0){throw 'Native query must report exactly one complete successful expected channel.'};return @($list.ToArray())}finally{if($reader){$reader.Dispose()}} } try{ $original=Get-WelaPsLoggingSnapshot;Save 'original.json' $original;$masks=Masks @@ -72,6 +72,7 @@ try{ $protected=@($original.ProtectedEventLogging.Keys|ForEach-Object {$_.Values}|Where-Object {$_.Name -eq 'EnableProtectedEventLogging' -and $_.Value -ne 0}) Assert ($protected.Count -eq 0) 'Protected logging must not obscure this plaintext event fixture.' # Test fixture only: prepare explicit disabled values; production has no disable action. + $mutationStarted=$true foreach($pair in @(@('ModuleLogging','EnableModuleLogging'),@('ScriptBlockLogging','EnableScriptBlockLogging'))){ $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null try{$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\'+$pair[0]);$key.SetValue($pair[1],0,[Microsoft.Win32.RegistryValueKind]::DWord);$key.Flush()}finally{if($key){$key.Dispose()};$base.Dispose()} @@ -95,7 +96,7 @@ try{ Assert (@($again.Results|Where-Object Status -eq AlreadyCompliant).Count -eq 3 -and -not (Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Second Configure makes no native writes.' Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $configured)) 'Repeated configuration preserves complete observed state.' $nonce='WELA_PS_LOG_'+[guid]::NewGuid().ToString('N');$workerPath=Join-Path $root ('worker-'+[guid]::NewGuid().ToString('N')+'.ps1') - $workerText="Microsoft.PowerShell.Utility\Write-Output -InputObject '$nonce'`r`n" + $workerText="Microsoft.PowerShell.Utility\Write-Output -InputObject '$nonce'`n" [IO.File]::WriteAllText($workerPath,$workerText,[Text.UTF8Encoding]::new($false));Save 'worker-source.json' @{Path=$workerPath;Sha256=(Get-FileHash $workerPath -Algorithm SHA256).Hash;Text=$workerText;Nonce=$nonce} $latest=Get-WinEvent -LogName 'Microsoft-Windows-PowerShell/Operational' -MaxEvents 1 -ErrorAction Stop;try{$watermark=[long]$latest.RecordId}finally{$latest.Dispose()} $process=Child 'event-worker' $configured.Engine.Path @('-NoLogo','-NoProfile','-NonInteractive','-File',$workerPath) @@ -104,7 +105,8 @@ try{ do{ $candidates=@(ReadEvents $process.Pid $watermark);$selected=@{} foreach($xml in $candidates){ - $doc=[xml]$xml;$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$system=$doc.SelectSingleNode('/e:Event/e:System',$ns);$data=@{} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=262144;$xmlReader=[Xml.XmlReader]::Create([IO.StringReader]::new($xml),$settings) + try{$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.PreserveWhitespace=$true;$doc.Load($xmlReader)}finally{$xmlReader.Dispose()};$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$system=$doc.SelectSingleNode('/e:Event/e:System',$ns);$data=@{} foreach($node in $doc.SelectNodes('/e:Event/e:EventData/e:Data',$ns)){if($data.ContainsKey($node.GetAttribute('Name'))){throw 'Duplicate native event field.'};$data[$node.GetAttribute('Name')]=$node.InnerText} $id=[int]$system.SelectSingleNode('e:EventID',$ns).InnerText if($system.SelectSingleNode('e:Provider',$ns).GetAttribute('Name') -cne 'Microsoft-Windows-PowerShell' -or $system.SelectSingleNode('e:Provider',$ns).GetAttribute('Guid').Trim('{}') -ine 'a0c1853b-5c40-4b15-8766-3cf1c58f985a' -or [int]$system.SelectSingleNode('e:Execution',$ns).GetAttribute('ProcessID') -ne $process.Pid -or $system.SelectSingleNode('e:Channel',$ns).InnerText -cne 'Microsoft-Windows-PowerShell/Operational' -or $system.SelectSingleNode('e:Computer',$ns).InnerText -ine $env:COMPUTERNAME){continue} @@ -127,12 +129,12 @@ try{ Assert ((ConvertTo-WelaPsLoggingKey (Get-WelaPsLoggingSnapshot)) -ceq (ConvertTo-WelaPsLoggingKey $wrong)) 'Refusal preserves the typed wrong value.' }catch{$failure=$_.ToString();Save 'failure.json' @{Error=$failure;Stack=$_.ScriptStackTrace}} finally{ - if($original){ + if($original -and $mutationStarted){ foreach($item in @(@('ScriptBlockLogging','EnableScriptBlockLogging'),@('ModuleLogging','EnableModuleLogging'),@('ModuleLogging\ModuleNames','Microsoft.PowerShell.Utility'))){try{RestoreValue $original.Machine $item[0] $item[1]}catch{$cleanupErrors+=$_.ToString()}} try{RemoveCreatedKeys $original.Machine}catch{$cleanupErrors+=$_.ToString()} try{$after=Get-WelaPsLoggingSnapshot;Save 'cleanup-after.json' $after;if((ConvertTo-WelaPsLoggingKey $after) -cne (ConvertTo-WelaPsLoggingKey $original)){$cleanupErrors+='Full policy/host/source/channel snapshot did not restore exactly.'};if($masks -and (Masks) -cne $masks){$cleanupErrors+='Audit masks changed.'}}catch{$cleanupErrors+=$_.ToString()} } - Save 'cleanup.json' @{Status=$(if($cleanupErrors.Count){'Failed'}else{'Restored'});Errors=$cleanupErrors;OriginalCaptured=[bool]$original;All59MasksUnchanged=($masks -and (Masks) -ceq $masks)} + Save 'cleanup.json' @{Status=$(if($cleanupErrors.Count){'Failed'}else{'Restored'});Errors=$cleanupErrors;OriginalCaptured=[bool]$original;MutationStarted=$mutationStarted;All59MasksUnchanged=($masks -and (Masks) -ceq $masks)} $artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object{[pscustomobject]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) Save 'manifest.json' @{Kind='WelaPowerShellLoggingNativeFixture';Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$script:count;Failure=$failure;CleanupErrors=$cleanupErrors;ReadyRuleCredit=0;Artifacts=$artifacts} } diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4634748f..c8ee6500 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security) + - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index cb0153b1..4523aadb 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security) + - Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security) From 8aee77cfeadfeb9e902bfd0b37e56047fe4356b9 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:46:25 +0900 Subject: [PATCH 4/9] Bound unrelated command-line policy inventory --- scripts/ProcessCommandline.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ProcessCommandline.ps1 b/scripts/ProcessCommandline.ps1 index 1c61f6bf..315f740f 100644 --- a/scripts/ProcessCommandline.ps1 +++ b/scripts/ProcessCommandline.ps1 @@ -19,10 +19,12 @@ function Get-WelaProcessCommandlineSnapshot { $key=$parent.OpenSubKey('Audit') $unselected=[pscustomobject][ordered]@{Values=@();Children=@()} if ($key) { + if ($key.ValueCount -gt 128 -or $key.SubKeyCount -gt 128) {throw 'Unrelated policy inventory exceeds its 128-entry bound.'} $unselected.Values=@($key.GetValueNames()|Sort-Object|Where-Object {$_ -ine 'ProcessCreationIncludeCmdLine_Enabled'}|ForEach-Object { [pscustomobject][ordered]@{Name=$_;Type=$key.GetValueKind($_).ToString();Value=$key.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)} }) $unselected.Children=@($key.GetSubKeyNames()|Sort-Object) + if (($unselected|ConvertTo-Json -Depth 12 -Compress).Length -gt 1048576) {throw 'Unrelated policy inventory exceeds its one Mi character bound.'} } } finally {if($key){$key.Dispose()};if($parent){$parent.Dispose()};$base.Dispose()} [pscustomobject][ordered]@{ From adaf3f96813a730cfefc0e4a72d991538ebedea6 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:48:45 +0900 Subject: [PATCH 5/9] Retain actual process identity for scoped logging and native attribution --- docs/powershell-logging.md | 2 +- scripts/PowerShellLogging.ps1 | 4 +++- tests/PowerShellLogging.Tests.ps1 | 4 ++-- tests/PowerShellLogging.Windows.Tests.ps1 | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/powershell-logging.md b/docs/powershell-logging.md index 0250e258..e81fe981 100644 --- a/docs/powershell-logging.md +++ b/docs/powershell-logging.md @@ -22,7 +22,7 @@ -Auto -BackupPath C:\WELA-Recovery\new-run -ResultsPath new-result.json ``` -Use a native 64-bit Windows PowerShell 5.1 or PowerShell 7 host. The actual Windows role, build, patch and installed Windows PowerShell engine are read; caller role/build overrides are refused. Reviewed families are Windows 11 builds 22000, 22621, 22631, 26100 and 26200, and Server 2022/2025 builds 20348/26100. Native client, joined-member, DC and AD CS acceptance remains separate from hosted standalone-server tests. `Winmgmt` and `EventLog` must already run; the command starts no service. Configure requires elevation and a fresh recovery directory. Plan and Configure require explicit controls; there is no implicit enable-everything selection. Audit without selection inventories existing policy only. +Use a native 64-bit Windows PowerShell 5.1 or PowerShell 7 host. The actual process SID, Windows role, build, patch and installed Windows PowerShell engine are read; impersonated callers are refused and caller role/build overrides are refused. Reviewed families are Windows 11 builds 22000, 22621, 22631, 26100 and 26200, and Server 2022/2025 builds 20348/26100. Native client, joined-member, DC and AD CS acceptance remains separate from hosted standalone-server tests. `Winmgmt` and `EventLog` must already run; the command starts no service. Configure requires elevation and a fresh recovery directory. Plan and Configure require explicit controls; there is no implicit enable-everything selection. Audit without selection inventories existing policy only. The array examples are PowerShell syntax. When launching `powershell.exe -File` from another shell, use a reviewed PowerShell wrapper to bind multiple array elements correctly. Literal module names can contain ASCII letters, digits, dots, underscores and hyphens, up to 128 characters each. Up to 32 unique names can be selected. Paths and wildcard patterns are refused; the exact `*` value is accepted **only when explicitly supplied** to request all modules. Installation and execution of arbitrary selected modules are not performed or asserted. diff --git a/scripts/PowerShellLogging.ps1 b/scripts/PowerShellLogging.ps1 index e16e33e9..ff9cc361 100644 --- a/scripts/PowerShellLogging.ps1 +++ b/scripts/PowerShellLogging.ps1 @@ -43,6 +43,8 @@ function Get-WelaPsLoggingSources { function Get-WelaPsLoggingSnapshot { if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'Use native 64-bit PowerShell on Windows.'} foreach($service in @('Winmgmt','EventLog')) {if((Get-Service -Name $service -ErrorAction Stop).Status -ne 'Running'){throw "$service must already be running; no service is started."}} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try {if(-not $identity.User -or $identity.ImpersonationLevel -ne [Security.Principal.TokenImpersonationLevel]::None){throw 'An actual non-impersonated process identity is required.'};$operator=[pscustomobject]@{Sid=$identity.User.Value;ImpersonationLevel=[string]$identity.ImpersonationLevel}}finally{$identity.Dispose()} $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop $computer=Get-CimInstance Win32_ComputerSystem -Property Name,Domain,DomainRole,PartOfDomain -ErrorAction Stop if ([string]$os.BuildNumber -notmatch '^\d+$' -or $computer.PartOfDomain -isnot [bool] -or $computer.DomainRole -notin @(0,1,2,3,4,5)) {throw 'Complete actual Windows role/build/join context is required.'} @@ -64,7 +66,7 @@ function Get-WelaPsLoggingSnapshot { try {$channel=Get-WinEvent -ListLog 'Microsoft-Windows-PowerShell/Operational' -ErrorAction Stop;$channelState=[pscustomobject]@{Name=[string]$channel.LogName;Enabled=[bool]$channel.IsEnabled;MaximumBytes=[long]$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Security=[string]$channel.SecurityDescriptor}}finally{if($channel -is [IDisposable]){$channel.Dispose()}} $patch=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' UBR if (-not $patch.ValueExists -or $patch.Type -ne 'DWord' -or $patch.Value -lt 0) {throw 'Exact native patch evidence is required.'} - [pscustomobject][ordered]@{Host=[pscustomobject]@{Computer=[string]$computer.Name;Domain=[string]$computer.Domain;Build=$build;Patch=$patch.Value;ProductType=$product;DomainRole=$role;PartOfDomain=$joined;CertSvcPresent=[bool](Get-Service CertSvc -ErrorAction SilentlyContinue)};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1';Version=$engine.Value;Path=$exe;Sha256=$engineHash;WelaHostVersion=$PSVersionTable.PSVersion.ToString()};Sources=@(Get-WelaPsLoggingSources);Machine=$machine;CurrentUser=$user;PowerShellCoreMachine=$coreMachine;PowerShellCoreUser=$coreUser;ProtectedEventLogging=$protected;Channel=$channelState} + [pscustomobject][ordered]@{Operator=$operator;Host=[pscustomobject]@{Computer=[string]$computer.Name;Domain=[string]$computer.Domain;Build=$build;Patch=$patch.Value;ProductType=$product;DomainRole=$role;PartOfDomain=$joined;CertSvcPresent=[bool](Get-Service CertSvc -ErrorAction SilentlyContinue)};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1';Version=$engine.Value;Path=$exe;Sha256=$engineHash;WelaHostVersion=$PSVersionTable.PSVersion.ToString()};Sources=@(Get-WelaPsLoggingSources);Machine=$machine;CurrentUser=$user;PowerShellCoreMachine=$coreMachine;PowerShellCoreUser=$coreUser;ProtectedEventLogging=$protected;Channel=$channelState} } function Get-WelaPsLoggingValue { param($Tree,[string]$Path,[string]$Name) diff --git a/tests/PowerShellLogging.Tests.ps1 b/tests/PowerShellLogging.Tests.ps1 index 49c2804c..3907ea69 100644 --- a/tests/PowerShellLogging.Tests.ps1 +++ b/tests/PowerShellLogging.Tests.ps1 @@ -7,7 +7,7 @@ function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action} function CloneFixture($Value){ConvertTo-WelaPsLoggingKey $Value|ConvertFrom-Json} function Row($Path,$Values=@(),$Children=@()){[pscustomobject]@{Path=$Path;Values=@($Values);Children=@($Children);Access='original-acl'}} function Fixture { - [pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='fixture';Build=20348};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1'};Sources=@('sha');Machine=[pscustomobject]@{Exists=$true;Keys=@((Row '' @() @('ModuleLogging','ScriptBlockLogging','Transcription')),(Row 'ModuleLogging' @([pscustomobject]@{Name='EnableModuleLogging';Type='DWord';Value=0}) @('ModuleNames')),(Row 'ModuleLogging\ModuleNames' @([pscustomobject]@{Name='existing';Type='String';Value='Existing.Module'})),(Row 'ScriptBlockLogging' @([pscustomobject]@{Name='EnableScriptBlockLogging';Type='DWord';Value=0},[pscustomobject]@{Name='EnableScriptBlockInvocationLogging';Type='DWord';Value=1})),(Row 'Transcription' @([pscustomobject]@{Name='EnableTranscripting';Type='DWord';Value=1})))};CurrentUser=@('preserved-user');PowerShellCoreMachine=@('preserved-core');PowerShellCoreUser=@('preserved-core-user');ProtectedEventLogging=@('preserved-protected');Channel=@('preserved-channel')} + [pscustomobject][ordered]@{Operator=[pscustomobject]@{Sid='S-1-5-21-1';ImpersonationLevel='None'};Host=[pscustomobject]@{Computer='fixture';Build=20348};Engine=[pscustomobject]@{Target='Windows PowerShell 5.1'};Sources=@('sha');Machine=[pscustomobject]@{Exists=$true;Keys=@((Row '' @() @('ModuleLogging','ScriptBlockLogging','Transcription')),(Row 'ModuleLogging' @([pscustomobject]@{Name='EnableModuleLogging';Type='DWord';Value=0}) @('ModuleNames')),(Row 'ModuleLogging\ModuleNames' @([pscustomobject]@{Name='existing';Type='String';Value='Existing.Module'})),(Row 'ScriptBlockLogging' @([pscustomobject]@{Name='EnableScriptBlockLogging';Type='DWord';Value=0},[pscustomobject]@{Name='EnableScriptBlockInvocationLogging';Type='DWord';Value=1})),(Row 'Transcription' @([pscustomobject]@{Name='EnableTranscripting';Type='DWord';Value=1})))};CurrentUser=@('preserved-user');PowerShellCoreMachine=@('preserved-core');PowerShellCoreUser=@('preserved-core-user');ProtectedEventLogging=@('preserved-protected');Channel=@('preserved-channel')} } function Mutate($Before,$Definition){ $after=CloneFixture $Before;$after.Machine.Exists=$true @@ -61,7 +61,7 @@ try { Reset;$script:corrupt=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'corrupt') Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1) 'Preservation failure stops later writes' Reset;$snapshot=CloneFixture $script:observed;$definition=$definitions[0];$after=Mutate $snapshot $definition;Assert-WelaPsLoggingTransition $snapshot $after $definition;Assert $true 'Exact additive transition accepted' - foreach($property in @('Host','Sources','CurrentUser','PowerShellCoreMachine','PowerShellCoreUser','ProtectedEventLogging','Channel')){$changed=CloneFixture $after;$changed.$property='drift';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unselected state'} + foreach($property in @('Operator','Host','Sources','CurrentUser','PowerShellCoreMachine','PowerShellCoreUser','ProtectedEventLogging','Channel')){$changed=CloneFixture $after;$changed.$property='drift';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'Unselected state'} $changed=CloneFixture $after;$changed.Machine.Keys[0].Access='new-acl';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'descriptor' $changed=CloneFixture $after;$changed.Machine.Keys=@($changed.Machine.Keys|Where-Object Path -ne Transcription);Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'disappeared' $changed=CloneFixture $after;$changed.Machine.Keys+=Row 'extra';Reject {Assert-WelaPsLoggingTransition $snapshot $changed $definition} 'unrequested' diff --git a/tests/PowerShellLogging.Windows.Tests.ps1 b/tests/PowerShellLogging.Windows.Tests.ps1 index 46e6fac9..d6be351d 100644 --- a/tests/PowerShellLogging.Windows.Tests.ps1 +++ b/tests/PowerShellLogging.Windows.Tests.ps1 @@ -111,7 +111,7 @@ try{ $id=[int]$system.SelectSingleNode('e:EventID',$ns).InnerText if($system.SelectSingleNode('e:Provider',$ns).GetAttribute('Name') -cne 'Microsoft-Windows-PowerShell' -or $system.SelectSingleNode('e:Provider',$ns).GetAttribute('Guid').Trim('{}') -ine 'a0c1853b-5c40-4b15-8766-3cf1c58f985a' -or [int]$system.SelectSingleNode('e:Execution',$ns).GetAttribute('ProcessID') -ne $process.Pid -or $system.SelectSingleNode('e:Channel',$ns).InnerText -cne 'Microsoft-Windows-PowerShell/Operational' -or $system.SelectSingleNode('e:Computer',$ns).InnerText -ine $env:COMPUTERNAME){continue} $time=[DateTimeOffset]::Parse($system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime')).UtcDateTime;if($time -lt [DateTimeOffset]::Parse($process.StartedUtc).UtcDateTime -or $time -gt [DateTimeOffset]::Parse($process.ExitedUtc).UtcDateTime){continue} - if($system.SelectSingleNode('e:Security',$ns).GetAttribute('UserID') -cne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value){continue} + if($system.SelectSingleNode('e:Security',$ns).GetAttribute('UserID') -cne $original.Operator.Sid){continue} if($id -eq 4104 -and $data.ScriptBlockText -ceq $workerText -and $data.Path -ieq $workerPath -and $data.MessageNumber -ceq '1' -and $data.MessageTotal -ceq '1' -and $data.ScriptBlockId -match '^[0-9a-f-]{36}$'){$selected['4104']=@($selected['4104'])+ $xml} if($id -eq 4103 -and $data.ContainsKey('Payload') -and $data.ContainsKey('ContextInfo') -and $data.Payload.Contains($nonce) -and $data.ContextInfo.Contains($workerPath)){$selected['4103']=@($selected['4103'])+ $xml} } From 75c978b9a253ac0fb21f9c41729c9ed437a63928 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:54:58 +0900 Subject: [PATCH 6/9] Preserve legacy positional CLI parameter bindings --- WELA.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WELA.ps1 b/WELA.ps1 index d1e47de9..9d2b6db0 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -1,6 +1,5 @@ param ( [string]$Cmd, - [ValidateSet("Audit","Plan","Configure")][string]$ProcessCommandlineAction = "Audit", [string]$OutType = "std", [switch]$Debug, [string]$Baseline, @@ -229,7 +228,8 @@ [ValidateRange(1,1024)][int]$MeasurementMaximumEvents = 256, [string]$MeasurementOutputPath, [switch]$MeasurementExportEvtx, - [switch]$Help + [switch]$Help, + [ValidateSet("Audit","Plan","Configure")][string]$ProcessCommandlineAction = "Audit" ) $WELAVersion = "2.2.0" From 4ab4c275a52929b7b1e4327ea46c9e028218e7bc Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:57:47 +0900 Subject: [PATCH 7/9] Preserve positional bindings and propagate native fixture failures --- WELA.ps1 | 8 ++++---- tests/PowerShellLogging.Cli.Tests.ps1 | 3 +++ tests/PowerShellLogging.Windows.Tests.ps1 | 5 +++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/WELA.ps1 b/WELA.ps1 index d6b438fa..3719b9fe 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -77,9 +77,6 @@ [string]$RuleEvidencePath, [string]$RuleCorpusPath, [string]$RuleManifestPath, - [ValidateSet('Audit','Plan','Configure')][string]$PowerShellLoggingAction = 'Audit', - [ValidateSet('ScriptBlock','Module')][string[]]$PowerShellLoggingControl, - [string[]]$PowerShellLoggingModuleName, [ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit', [string]$TranscriptDirectory, [ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit', @@ -231,7 +228,10 @@ [ValidateRange(1,1024)][int]$MeasurementMaximumEvents = 256, [string]$MeasurementOutputPath, [switch]$MeasurementExportEvtx, - [switch]$Help + [switch]$Help, + [ValidateSet('Audit','Plan','Configure')][string]$PowerShellLoggingAction = 'Audit', + [ValidateSet('ScriptBlock','Module')][string[]]$PowerShellLoggingControl, + [string[]]$PowerShellLoggingModuleName ) $WELAVersion = "2.2.0" diff --git a/tests/PowerShellLogging.Cli.Tests.ps1 b/tests/PowerShellLogging.Cli.Tests.ps1 index b5304391..3a1da779 100644 --- a/tests/PowerShellLogging.Cli.Tests.ps1 +++ b/tests/PowerShellLogging.Cli.Tests.ps1 @@ -1,4 +1,7 @@ $ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$parameterAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'),[ref]$null,[ref]$null).ParamBlock.Parameters +$names=@($parameterAst|ForEach-Object {$_.Name.VariablePath.UserPath});$helpIndex=[array]::IndexOf($names,'Help') +foreach($name in @('PowerShellLoggingAction','PowerShellLoggingControl','PowerShellLoggingModuleName')){if([array]::IndexOf($names,$name) -le $helpIndex){throw 'New logging parameters must follow existing Help to preserve legacy positional binding.'};$count++} $cases=@( @{Args=@('powershell-logging','-Help');Code=0;Pattern='Windows PowerShell 5.1'}, @{Args=@('configure','-PowerShellLoggingAction','Configure');Code=1;Pattern='require powershell-logging'}, diff --git a/tests/PowerShellLogging.Windows.Tests.ps1 b/tests/PowerShellLogging.Windows.Tests.ps1 index d6be351d..a494fbb7 100644 --- a/tests/PowerShellLogging.Windows.Tests.ps1 +++ b/tests/PowerShellLogging.Windows.Tests.ps1 @@ -37,8 +37,9 @@ param([string]$Repository,[string]$Request) $ErrorActionPreference='Stop' $data=Get-Content -LiteralPath $Request -Raw|ConvertFrom-Json;$options=@{} foreach($property in $data.PSObject.Properties){$options[$property.Name]=$property.Value} +$global:LASTEXITCODE=0 & (Join-Path $Repository 'WELA.ps1') @options -exit 0 +exit $LASTEXITCODE '@ | Set-Content -LiteralPath $wrapper -Encoding UTF8 function Public([string]$Label,[hashtable]$Parameters,[int]$ExpectedExit=0){ $Parameters.Cmd='powershell-logging';$Parameters.ResultsPath=Join-Path $root ($Label+'.json');$request=Join-Path $root ($Label+'-request.json');$Parameters|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $request -Encoding UTF8 @@ -134,7 +135,7 @@ finally{ try{RemoveCreatedKeys $original.Machine}catch{$cleanupErrors+=$_.ToString()} try{$after=Get-WelaPsLoggingSnapshot;Save 'cleanup-after.json' $after;if((ConvertTo-WelaPsLoggingKey $after) -cne (ConvertTo-WelaPsLoggingKey $original)){$cleanupErrors+='Full policy/host/source/channel snapshot did not restore exactly.'};if($masks -and (Masks) -cne $masks){$cleanupErrors+='Audit masks changed.'}}catch{$cleanupErrors+=$_.ToString()} } - Save 'cleanup.json' @{Status=$(if($cleanupErrors.Count){'Failed'}else{'Restored'});Errors=$cleanupErrors;OriginalCaptured=[bool]$original;MutationStarted=$mutationStarted;All59MasksUnchanged=($masks -and (Masks) -ceq $masks)} + Save 'cleanup.json' @{Status=$(if($cleanupErrors.Count){'Failed'}elseif($mutationStarted){'Restored'}else{'NotMutated'});Errors=$cleanupErrors;OriginalCaptured=[bool]$original;MutationStarted=$mutationStarted;All59MasksUnchanged=($masks -and (Masks) -ceq $masks)} $artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object{[pscustomobject]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) Save 'manifest.json' @{Kind='WelaPowerShellLoggingNativeFixture';Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$script:count;Failure=$failure;CleanupErrors=$cleanupErrors;ReadyRuleCredit=0;Artifacts=$artifacts} } From fb0a424da8987ee3e0b3e175ef11860365ade8c2 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:19:20 +0900 Subject: [PATCH 8/9] Reject scoped logging changes that overflow bounded policy inventories --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/powershell-logging.md | 4 ++-- scripts/PowerShellLogging.ps1 | 20 ++++++++++++++++++++ tests/PowerShellLogging.Tests.ps1 | 19 +++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 7 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index ccbcbfe4..38ecb054 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -6,7 +6,7 @@ **改善:** -- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security) +- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、事前の記録容量、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security) - 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e18486..6757423a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ **Improvements:** -- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security) +- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, preflight inventory-capacity checks, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security) - Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security) diff --git a/docs/powershell-logging.md b/docs/powershell-logging.md index e81fe981..587b182b 100644 --- a/docs/powershell-logging.md +++ b/docs/powershell-logging.md @@ -39,7 +39,7 @@ An existing matching module entry is retained. The command does not delete or re Microsoft documents machine policy precedence over user policy, module pipeline logging, script-block logging, and the additional volume generated by invocation start/stop logging in [Windows PowerShell Group Policy settings](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1). The [ADMX mapping](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy#enablemodulelogging) documents the ModuleLogging registry location. This command offers explicit source controls; it does not import a Microsoft/CIS/ASD baseline or claim complete compliance with one. -`SOFTWARE\Policies` is [shared across registry views](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys). Both views are compared before a native-view write; no literal `Wow6432Node` policy tree is created. Snapshots include the Windows PowerShell machine and current-user policy trees, each bounded to 64 keys, eight levels, 128 values per key and one Mi character serialized data. Incomplete, denied, unstable or excessive trees are refused. Existing owner/group/DACL observations are recorded; these snapshots do not claim registry SACL enumeration or effective access for other principals. +`SOFTWARE\Policies` is [shared across registry views](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys). Both views are compared before a native-view write; no literal `Wow6432Node` policy tree is created. Snapshots include the Windows PowerShell machine and current-user policy trees, each bounded to 64 keys, eight levels, 128 values per key and one Mi character serialized data. Incomplete, denied, unstable or excessive trees are refused. Preflight projects all selected additions and refuses predictable key/value-count or serialized-value growth beyond these limits before any journal or write. New keys' inherited descriptor sizes remain subject to native readback. Existing owner/group/DACL observations are recorded; these snapshots do not claim registry SACL enumeration or effective access for other principals. PowerShell 7 has [separate PowerShell Core settings and an optional Windows-policy fallback](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-7.5). Its registry policy trees are observed and preserved; `powershell.config.json`, session behavior and fallback selection are not assessed. **A PowerShell 7 deployment using that fallback may inherit changes to Windows PowerShell policy.** Running WELA under PowerShell 7 does not establish PowerShell 7 event coverage. Existing sessions are not restarted or asserted to adopt the changes. @@ -55,7 +55,7 @@ For manual recovery, compare the original journal, confirmed results and current ## Native validation and limits -The focused suite covers explicit selection, exact types, module-name collisions, preservation, journal-before-write, missing-key creation, idempotence, dry runs, refused output reuse, partial failures and approval-time drift. Public CLI tests reject unrelated/profile/role options before dispatch. +The focused suite covers explicit selection, exact types, module-name collisions, preservation, journal-before-write, missing-key creation, inventory-capacity boundaries, idempotence, dry runs, refused output reuse, partial failures and approval-time drift. Public CLI tests reject unrelated/profile/role options before dispatch. The opt-in disposable Server 2022/2025 matrix runs WELA under Windows PowerShell 5.1 and PowerShell 7. It saves native state, prepares selected disabled values, exercises the public Audit/Plan/DryRun/Configure path, checks original journals and idempotence, then launches a new fixed **native Windows PowerShell 5.1** utility command with a unique benign marker. Native Operational event XML must match the owned child PID, provider GUID/name, actual SID, computer, record boundary and measured process-lifetime interval; script-block evidence additionally matches the exact script path/text and complete fragment counts. Only the fixture prepares policy or generates events. Wrong-type refusal and exact typed policy/key, channel and all-59-mask cleanup are required. Native event artifacts must be reviewed before claiming a matrix run passed. diff --git a/scripts/PowerShellLogging.ps1 b/scripts/PowerShellLogging.ps1 index ff9cc361..9d76b441 100644 --- a/scripts/PowerShellLogging.ps1 +++ b/scripts/PowerShellLogging.ps1 @@ -85,12 +85,32 @@ function Get-WelaPsLoggingDefinitions { if ($Control -contains 'ScriptBlock') {[pscustomobject]@{Control='ScriptBlock';Path='ScriptBlockLogging';Name='EnableScriptBlockLogging';Type='DWord';Value=1}} } function Test-WelaPsLoggingValue {param($Snapshot,$Definition) $value=Get-WelaPsLoggingValue $Snapshot.Machine $Definition.Path $Definition.Name;return $null -ne $value -and $value.Type -ceq $Definition.Type -and (ConvertTo-WelaPsLoggingKey $value.Value) -ceq (ConvertTo-WelaPsLoggingKey $Definition.Value)} +function Assert-WelaPsLoggingCapacity { + param($Snapshot,[array]$Definitions) + # Project predictable inventory growth before writing; new inherited access descriptors + # still require native readback. Projection never mutates the captured original state. + $tree=ConvertTo-WelaPsLoggingKey $Snapshot.Machine|ConvertFrom-Json;$rows=@{} + foreach($row in $tree.Keys){$rows[$row.Path]=$row} + foreach($definition in $Definitions){ + $paths=@('');$path='';foreach($part in $definition.Path.Split('\')){$path=if($path){$path+'\'+$part}else{$part};$paths+=$path} + foreach($path in $paths){ + if(-not $rows.ContainsKey($path)){$rows[$path]=[pscustomobject]@{Path=$path;Values=@();Children=@();Access=''}} + if($path){$separator=$path.LastIndexOf('\');$parent=if($separator -ge 0){$path.Substring(0,$separator)}else{''};$leaf=if($separator -ge 0){$path.Substring($separator+1)}else{$path};$rows[$parent].Children=@(@($rows[$parent].Children)+$leaf|Sort-Object -Unique)} + } + $row=$rows[$definition.Path];$row.Values=@($row.Values|Where-Object Name -ine $definition.Name)+[pscustomobject]@{Name=$definition.Name;Type=$definition.Type;Value=$definition.Value} + if($row.Values.Count -gt 128){throw 'Selected changes exceed the 128-value policy inventory capacity; no write is safe.'} + } + if($rows.Count -gt 64){throw 'Selected changes exceed the 64-key policy inventory capacity; no write is safe.'} + $tree.Exists=($rows.Count -gt 0);$tree.Keys=@($rows.Values|Sort-Object Path) + if((ConvertTo-WelaPsLoggingKey $tree).Length -gt 1048576){throw 'Selected changes exceed the policy snapshot character capacity; no write is safe.'} +} function Assert-WelaPsLoggingKnown { param($Snapshot,[array]$Definitions) foreach ($definition in $Definitions) { $value=Get-WelaPsLoggingValue $Snapshot.Machine $definition.Path $definition.Name if ($value -and ($value.Type -cne $definition.Type -or ($definition.Type -eq 'DWord' -and $value.Value -notin @(0,1)) -or ($definition.Type -eq 'String' -and $value.Value -cne $definition.Value))) {throw "Selected policy value has an unknown type/value or a name collision: $($definition.Path)/$($definition.Name)."} } + Assert-WelaPsLoggingCapacity $Snapshot $Definitions if (@($Definitions|Where-Object Control -eq Module).Count) { foreach($key in @($Snapshot.Machine.Keys|Where-Object Path -ieq 'ModuleLogging\ModuleNames')) {foreach($value in $key.Values) {if($value.Type -cne 'String' -or [string]::IsNullOrWhiteSpace($value.Value)){throw 'Existing module-name policy contains an unsupported type/empty value; preserve and review it.'}}} } diff --git a/tests/PowerShellLogging.Tests.ps1 b/tests/PowerShellLogging.Tests.ps1 index 3907ea69..1fe04866 100644 --- a/tests/PowerShellLogging.Tests.ps1 +++ b/tests/PowerShellLogging.Tests.ps1 @@ -56,6 +56,25 @@ try { Reset;$badName=Get-WelaPsLoggingValue $script:observed.Machine 'ModuleLogging\ModuleNames' existing;$badName.Type='DWord';$badName.Value=1 $bad=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'badname') Assert ($bad.ExitCode -eq 1 -and -not (Test-Path (Join-Path $root 'badname'))) 'Unknown module value fails entire preflight' + foreach($countBefore in @(127,128)){ + Reset;$row=@($script:observed.Machine.Keys|Where-Object Path -eq 'ModuleLogging\ModuleNames')[0] + $row.Values=@(1..$countBefore|ForEach-Object{[pscustomobject]@{Name=('Existing'+$_);Type='String';Value=('Existing'+$_)}}) + $capacityPath=Join-Path $root ('value-capacity-'+$countBefore);$captured=ConvertTo-WelaPsLoggingKey $script:observed + $capacity=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName NewModule -DryRun -BackupPath $capacityPath + Assert (($capacity.ExitCode -eq 0) -eq ($countBefore -eq 127)) '127 names permit one addition; 128 names refuse before the first write' + Assert ($script:writes -eq 0 -and -not (Test-Path $capacityPath) -and (ConvertTo-WelaPsLoggingKey $script:observed) -ceq $captured) 'Capacity preflight preserves the snapshot without writes or journals' + } + foreach($countBefore in @(63,64)){ + Reset;$script:observed.Machine.Keys=@((Row '' @() @('ModuleLogging')),(Row 'ModuleLogging' @([pscustomobject]@{Name='EnableModuleLogging';Type='DWord';Value=0}))) + foreach($index in 1..($countBefore-2)){$name='Existing'+$index;$script:observed.Machine.Keys+=Row $name;$script:observed.Machine.Keys[0].Children+=$name} + $capacity=Invoke-WelaPowerShellLogging -Action Plan -Control Module -ModuleName NewModule + Assert (($capacity.ExitCode -eq 0) -eq ($countBefore -eq 63)) '63 keys permit the missing selected key; 64 keys refuse predictable readback overflow' + } + Reset;$row=@($script:observed.Machine.Keys|Where-Object Path -eq Transcription)[0];$row.Values+=[pscustomobject]@{Name='LargeUnrelated';Type='String';Value=''} + $space=1048576-(ConvertTo-WelaPsLoggingKey $script:observed.Machine).Length;$row.Values[-1].Value='x'*$space + Assert ((ConvertTo-WelaPsLoggingKey $script:observed.Machine).Length -eq 1048576) 'Character-cap boundary fixture fits the current reader exactly' + $capacity=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName NewModule -Auto -BackupPath (Join-Path $root 'character-capacity') + Assert ($capacity.ExitCode -eq 1 -and $script:writes -eq 0 -and -not (Test-Path (Join-Path $root 'character-capacity'))) 'Predictable serialized-value growth beyond character cap is refused before writes' Reset;$script:failWrite=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'writefailure') Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1 -and @($failed.Results|Where-Object Status -eq Skipped).Count -eq 2) 'Native failure stops and explicitly reports later writes' Reset;$script:corrupt=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'corrupt') diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 66ce16d7..99e27073 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -9,7 +9,7 @@ **改善:** -- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security) +- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、事前の記録容量、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security) - 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 7c3b626e..25b6dba4 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -9,7 +9,7 @@ **Improvements:** -- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security) +- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, preflight inventory-capacity checks, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security) - Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security) From 53d9eddb867fdca5a7f2e318414ac17beac0f228 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:44:16 +0900 Subject: [PATCH 9/9] Preserve literal registry types when projecting logging capacity --- scripts/PowerShellLogging.ps1 | 8 ++++++-- tests/PowerShellLogging.Tests.ps1 | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/PowerShellLogging.ps1 b/scripts/PowerShellLogging.ps1 index 9d76b441..b869c8f6 100644 --- a/scripts/PowerShellLogging.ps1 +++ b/scripts/PowerShellLogging.ps1 @@ -89,8 +89,12 @@ function Assert-WelaPsLoggingCapacity { param($Snapshot,[array]$Definitions) # Project predictable inventory growth before writing; new inherited access descriptors # still require native readback. Projection never mutates the captured original state. - $tree=ConvertTo-WelaPsLoggingKey $Snapshot.Machine|ConvertFrom-Json;$rows=@{} - foreach($row in $tree.Keys){$rows[$row.Path]=$row} + # JSON round-trips can reinterpret literal ISO-date registry strings as DateTime. + $tree=[pscustomobject]@{Exists=$Snapshot.Machine.Exists;Keys=@()};$rows=@{} + foreach($row in $Snapshot.Machine.Keys){ + $values=@(foreach($entry in $row.Values){$data=$entry.Value;if($data -is [Array]){$data=$data.Clone()};[pscustomobject]@{Name=$entry.Name;Type=$entry.Type;Value=$data}}) + $rows[$row.Path]=[pscustomobject]@{Path=$row.Path;Values=$values;Children=@($row.Children);Access=$row.Access} + } foreach($definition in $Definitions){ $paths=@('');$path='';foreach($part in $definition.Path.Split('\')){$path=if($path){$path+'\'+$part}else{$part};$paths+=$path} foreach($path in $paths){ diff --git a/tests/PowerShellLogging.Tests.ps1 b/tests/PowerShellLogging.Tests.ps1 index 1fe04866..09276941 100644 --- a/tests/PowerShellLogging.Tests.ps1 +++ b/tests/PowerShellLogging.Tests.ps1 @@ -75,6 +75,13 @@ try { Assert ((ConvertTo-WelaPsLoggingKey $script:observed.Machine).Length -eq 1048576) 'Character-cap boundary fixture fits the current reader exactly' $capacity=Invoke-WelaPowerShellLogging -Action Configure -Control Module -ModuleName NewModule -Auto -BackupPath (Join-Path $root 'character-capacity') Assert ($capacity.ExitCode -eq 1 -and $script:writes -eq 0 -and -not (Test-Path (Join-Path $root 'character-capacity'))) 'Predictable serialized-value growth beyond character cap is refused before writes' + $literalSnapshot=Fixture;$row=@($literalSnapshot.Machine.Keys|Where-Object Path -eq Transcription)[0] + $row.Values+=@(1..64|ForEach-Object{[pscustomobject]@{Name=('LiteralDate'+$_);Type='String';Value='2026-09-21T00:00:00.000Z'}}) + $row.Values+=[pscustomobject]@{Name='LiteralBytes';Type='Binary';Value=[byte[]]@(0,127,255)},[pscustomobject]@{Name='LiteralStrings';Type='MultiString';Value=[string[]]@('2026-09-21T00:00:00.000Z','')};$row.Values+=[pscustomobject]@{Name='LiteralFiller';Type='String';Value=''};$space=1048576-(ConvertTo-WelaPsLoggingKey $literalSnapshot.Machine).Length;$row.Values[-1].Value='x'*$space + $literalBefore=ConvertTo-WelaPsLoggingKey $literalSnapshot + Reject {Assert-WelaPsLoggingCapacity $literalSnapshot @(Get-WelaPsLoggingDefinitions @('Module') @('NewModule'))} 'character capacity' + Assert ((ConvertTo-WelaPsLoggingKey $literalSnapshot) -ceq $literalBefore -and $row.Values[1].Value -is [string]) 'Capacity projection preserves literal ISO strings and the caller snapshot' + Assert (($row.Values|Where-Object Name -eq LiteralBytes).Value -is [byte[]] -and ($row.Values|Where-Object Name -eq LiteralStrings).Value -is [string[]]) 'Projection retains typed binary and multi-string caller data' Reset;$script:failWrite=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'writefailure') Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 1 -and @($failed.Results|Where-Object Status -eq Skipped).Count -eq 2) 'Native failure stops and explicitly reports later writes' Reset;$script:corrupt=$true;$failed=Invoke-WelaPowerShellLogging -Action Configure -Control Module,ScriptBlock -ModuleName Microsoft.PowerShell.Utility -Auto -BackupPath (Join-Path $root 'corrupt')