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/3] 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 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 2/3] 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 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 3/3] 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"