diff --git a/.gitattributes b/.gitattributes index 7f0dff24..26dc232b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -58,3 +58,8 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/WmiNamespaceAuditing.ps1 text eol=lf /scripts/WefArrival.ps1 text eol=lf /tests/WmiProbe*.ps1 text eol=lf + +# Reviewed WEC state plans bind native setter and runtime source bytes. +/scripts/WecState* text eol=lf +/scripts/WecRuntime* text eol=lf +/tests/WecState* text eol=lf diff --git a/.github/workflows/ipsec-prerequisites.yml b/.github/workflows/ipsec-prerequisites.yml new file mode 100644 index 00000000..020d7f1c --- /dev/null +++ b/.github/workflows/ipsec-prerequisites.yml @@ -0,0 +1,45 @@ +name: Native IPsec prerequisite evidence +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'modules/AuditProfiles.psm1' + - 'scripts/Configuration.ps1' + - 'scripts/IpsecPrerequisites.ps1' + - 'tests/IpsecPrerequisites*' + - '.github/workflows/ipsec-prerequisites.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-ipsec: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Native prerequisite and public configure proof in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/IpsecPrerequisites.Tests.ps1 + ./tests/IpsecPrerequisites.Windows.Tests.ps1 -AllowDisposablePolicyWrite -AllowDisposableIpsecRule + - name: Native prerequisite and public configure proof in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/IpsecPrerequisites.Tests.ps1 + ./tests/IpsecPrerequisites.Windows.Tests.ps1 -AllowDisposablePolicyWrite -AllowDisposableIpsecRule + - name: Retain native observations and restoration evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ipsec-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-ipsec-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47eb549a..1cfbf69a 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/eventlog-recovery.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/eventlog-recovery.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.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/ipsec-prerequisites.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true @@ -77,4 +77,4 @@ jobs: with: name: wela-documents path: | - ./*.pdf \ No newline at end of file + ./*.pdf diff --git a/.github/workflows/smb-runtime-activation.yml b/.github/workflows/smb-runtime-activation.yml new file mode 100644 index 00000000..dcb64609 --- /dev/null +++ b/.github/workflows/smb-runtime-activation.yml @@ -0,0 +1,58 @@ +name: SMB runtime audit activation +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/SmbRuntimeActivation.ps1' + - 'scripts/SmbAuditing.ps1' + - 'scripts/Configuration.ps1' + - 'scripts/WefArrival.ps1' + - 'tests/SmbRuntimeActivation*' + - '.github/workflows/smb-runtime-activation.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-smb-activation: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + shell: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Focused regressions in Windows PowerShell 5.1 + if: matrix.shell == 'powershell' + shell: powershell + run: | + ./tests/SmbRuntimeActivation.Tests.ps1 + ./tests/SmbRuntimeActivation.Cli.Tests.ps1 + - name: Native activation and restoration in Windows PowerShell 5.1 + if: matrix.shell == 'powershell' + shell: powershell + env: + WELA_DISPOSABLE_SMB_ACTIVATION: 'true' + run: ./tests/SmbRuntimeActivation.Windows.Tests.ps1 + - name: Focused regressions in PowerShell 7 + if: matrix.shell == 'pwsh' + shell: pwsh + run: | + ./tests/SmbRuntimeActivation.Tests.ps1 + ./tests/SmbRuntimeActivation.Cli.Tests.ps1 + - name: Native activation and restoration in PowerShell 7 + if: matrix.shell == 'pwsh' + shell: pwsh + env: + WELA_DISPOSABLE_SMB_ACTIVATION: 'true' + run: ./tests/SmbRuntimeActivation.Windows.Tests.ps1 + - name: Retain native evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: smb-runtime-${{ matrix.os }}-${{ matrix.shell }} + path: ${{ runner.temp }}/wela-smb-runtime-*/ + if-no-files-found: warn diff --git a/.github/workflows/wec-state.yml b/.github/workflows/wec-state.yml new file mode 100644 index 00000000..207cad01 --- /dev/null +++ b/.github/workflows/wec-state.yml @@ -0,0 +1,48 @@ +name: Reviewed existing WEC subscription state +on: + push: + paths: ['WELA.ps1', 'scripts/WecState*', 'tests/WecState*', '.github/workflows/wec-state.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wec-state: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Portable guards in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WecState.Tests.ps1 + ./tests/WecState.Cli.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + - name: Actual owned Enabled transitions in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WecState.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Portable guards in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WecState.Tests.ps1 + ./tests/WecState.Cli.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + - name: Actual owned Enabled transitions in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecState.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Retain native state evidence and cleanup receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: wec-state-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-wec-state-* + if-no-files-found: ignore + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index d895678b..98acf500 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -6,6 +6,12 @@ - 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security) +- `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security) + +- 既存のWindows標準ソース開始型購読1件のEnabledだけをレビュー後に変更する`wec-state`のPlan/Applyを追加しました。送信元認可と完全な定義、実行者・ログオン・トークン、永続的な変更前記録とネイティブ読戻しを確認して他の設定を保持し、既に一致する状態では保存・再有効化を行いません。稼働状況の観測と配送・ブックマークの継続性を区別し、使い捨てWindowsテストで実際の有効/無効切替と所有リソース・サービス状態の復元を確認します。 (関連 #368) (@Shirofune-Security) + +- 強化プロファイルのオプション IPsec Main Mode 監査に、Windows ネイティブの前提条件確認を追加しました。有効なポリシーストアのルールと現在の関連付けを読み取り、適用可能・確認範囲内で未観測・不明を区別します。共有設定処理は書き込み直前に再確認し、明示的な選択とカスタムプロファイルの指定を保持します。Server 2022/2025・PowerShell 5.1/7 の一時ルールを使ったテストで監査ポリシーの復元を確認します。ネゴシエーション、イベント生成、Sigma の対応は保証しません。 (#370) (@Shirofune-Security) + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe8796de..932751c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security) +- Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security) + +- Added reviewed `wec-state` Plan/Apply for the Enabled flag of one existing native source-initiated subscription. Exact authorization and complete definition checks, operator/logon/token guards, durable pending evidence and native readback preserve other settings; matching states never save or reactivate. Runtime remains separate, and interrupted delivery/bookmark continuity require multi-host validation. Disposable Windows lifecycle tests restore owned resources and service state. (Related #368) (@Shirofune-Security) + +- Added native prerequisite evidence for the stronger profile's optional IPsec Main Mode auditing. Effective-store rule and current association observations distinguish scoped applicability, absence and unknown results; both shared configuration paths recheck before writing and preserve explicit selection/custom-profile intent. Native disposable-rule tests cover Server 2022/2025 and PowerShell 5.1/7 with exact audit-policy restoration, without negotiation/event or Sigma claims. (#370) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 41934b98..5817eb95 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -24,6 +24,8 @@ [ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384, [string]$HtmlPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit', + [ValidateSet('Plan','Activate')][string]$SmbRuntimeAction = 'Plan', + [string]$SmbRuntimeOutputPath, [ValidateSet('Audit', 'Plan', 'Configure', 'Rollback')][string]$AdSaclAction = 'Audit', [string]$AdServer, [ValidateSet('MdiDomain', 'MdiConfiguration', 'PkiObjects')][string[]]$AdSaclProfile, @@ -129,6 +131,13 @@ [string]$WecUpdatePlanPath, [string]$WecUpdatePlanHash, [string]$WecUpdateOutputPath, + [ValidateSet('Plan','Apply')][string]$WecStateAction = 'Plan', + [string]$WecStateId, + [string[]]$WecStateSourceSid, + [ValidateSet('Enabled','Disabled')][string]$WecStateDesired, + [string]$WecStatePlanPath, + [string]$WecStatePlanHash, + [string]$WecStateOutputPath, [ValidateSet('Audit','Plan','Configure')][string]$DnsAction = 'Audit', [ValidateSet('Enabled','Disabled')][string]$DnsState, [ValidateSet('Preserve','Circular','Retain')][string]$DnsRetention = 'Preserve', @@ -165,6 +174,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AuditIntegrity.ps1") . (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1") . (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1") +. (Join-Path $ScriptRoot "scripts/SmbRuntimeActivation.ps1") . (Join-Path $ScriptRoot "scripts/LdapDiagnostics.ps1") . (Join-Path $ScriptRoot "scripts/ControlApplicability.ps1") . (Join-Path $ScriptRoot "scripts/NativeValidation.ps1") @@ -192,6 +202,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorA Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/WefDeployment.ps1") . (Join-Path $ScriptRoot "scripts/WecUpdate.ps1") +. (Join-Path $ScriptRoot "scripts/WecState.ps1") . (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1") . (Join-Path $ScriptRoot "scripts/AuditScoring.ps1") . (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") @@ -534,7 +545,7 @@ function Invoke-WelaProfileCommand { else { Write-Host "Planning for another role/build: effective state remains Unknown." } } elseif ($Command -ne 'plan') { throw "Audit and configure require Windows. Offline planning requires explicit -Role and -Build." } - $plan = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $context.Role -Build $context.Build -Current $current -IncludeOptional:$script:IncludeOptional @planArguments + $plan = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $context.Role -Build $context.Build -Current $current -IncludeOptional:$script:IncludeOptional -ObserveIpsec:$saclLive @planArguments if ($script:ProfileFile) { Assert-WelaCustomProfileSource $custom.customSource if ($plan.CustomProfileSource.Sha256 -cne $custom.customSource.Sha256) { throw 'Custom profile changed during host assessment.' } @@ -548,6 +559,9 @@ function Invoke-WelaProfileCommand { Write-Host "Audit precedence: $($precedence.State); required SCENoApplyLegacyAuditPolicy=1 (DWORD). $($precedence.Diagnostic)" if ($precedence.PolicySource) { Write-Host $precedence.PolicySource.Description } Show-WelaAuditProfilePrerequisites -Plan $plan + foreach ($policy in $plan.policies) { + if ($policy.conditionalPrerequisite) { Write-Host "Conditional prerequisite - $($policy.id): $($policy.conditionalPrerequisite.Status). $($policy.conditionalPrerequisite.Limitations)" -ForegroundColor DarkYellow } + } Write-Host "Targeted SACL companion plan: $($saclPlan.Mode), $($saclPlan.Targets.Count) targets; $($saclPlan.TelemetryGap)" -ForegroundColor DarkYellow $saclPlan.Targets | Select-Object Scope, Path, Rights, Inheritance, PolicyMode, @{Name='PathState';Expression={$_.Observation.PathState}} | Format-Table -AutoSize $result = $plan @@ -1933,6 +1947,8 @@ Usage: # Firewall text logging is opt-in; it does not change firewall enforcement or rules. ./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json ./WELA.ps1 smb-auditing -SmbAction Plan + ./WELA.ps1 smb-runtime -SmbRuntimeAction Plan + ./WELA.ps1 smb-runtime -SmbRuntimeAction Activate -SmbRuntimeOutputPath C:\Evidence\new-smb -Auto ./WELA.ps1 rule-eligibility -ResultsPath eligibility.json -HtmlPath eligibility.html ./WELA.ps1 event-measurement -MeasurementChannel Security ./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx @@ -1972,6 +1988,7 @@ Usage: ./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes ./WELA.ps1 adcs-resume -Help # Review a pending CA auditing restart ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write + ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription ./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence ./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event @@ -1989,6 +2006,8 @@ Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' } +if ($Cmd -ne 'smb-runtime' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'SmbRuntime*' }).Count) {throw 'SmbRuntime options require smb-runtime. No command was run.'} +if ($Cmd -eq 'smb-runtime' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','SmbRuntimeAction','SmbRuntimeOutputPath','Auto','DryRun','Help') }).Count) {throw 'smb-runtime accepts only its dedicated options, Auto and DryRun. No command was run.'} if ($Cmd -eq 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ChannelReadName','ChannelReadOutputPath','Help') }).Count) { throw 'channel-read accepts only dedicated channel/output options. No command was run.' } if ($Cmd -ne 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Measurement*'}).Count) {throw 'Measurement options require event-measurement. No command was run.'} @@ -2050,6 +2069,8 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) { if ($Cmd -ne 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'EventRecovery*'}).Count) {throw 'EventRecovery options require eventlog-recovery.'} if ($Cmd -eq 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EventRecoveryAction','EventRecoveryJournalPath','EventRecoveryOriginalResultsPath','EventRecoveryLog','EventRecoveryPlanPath','EventRecoveryPlanHash','EventRecoveryOutputPath','EventRecoveryAllowShrink','EventRecoveryAllowRetentionChange','Help')}).Count) {throw 'eventlog-recovery accepts only dedicated options.'} +if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'} +if ($Cmd -eq 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecStateAction','WecStateId','WecStateSourceSid','WecStateDesired','WecStatePlanPath','WecStatePlanHash','WecStateOutputPath','Help')}).Count) {throw 'wec-state accepts only dedicated options.'} if ($Cmd -ne 'wec-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecUpdate*'}).Count) {throw 'WecUpdate options require wec-update.'} if ($Cmd -eq 'wec-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecUpdateAction','WecUpdateId','WecUpdateSourceSid','WecUpdateQueryPath','WecUpdateDescription','WecUpdatePlanPath','WecUpdatePlanHash','WecUpdateOutputPath','Help')}).Count) {throw 'wec-update accepts only dedicated options.'} if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecRuntime*'}).Count) { @@ -2132,6 +2153,7 @@ if ($DryRun -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure' -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and + -not ($Cmd -eq 'smb-runtime' -and $SmbRuntimeAction -eq 'Activate') -and -not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and -not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure') -and -not ($Cmd -in @('wef-source','wec-collector') -and $WefAction -eq 'Configure') -and @@ -2258,6 +2280,15 @@ switch ($Cmd.ToLower()) { $report=Invoke-WelaEventLogRecovery @arguments;$report if($report.ExitCode){exit $report.ExitCode} } + 'wec-state' { + if ($Help) {Write-Host 'Usage: wec-state [-WecStateAction Plan] -WecStateId ID -WecStateSourceSid SID -WecStateDesired Enabled|Disabled -WecStateOutputPath new-directory; then Apply with -WecStatePlanPath reviewed-plan.json -WecStatePlanHash SHA256 -WecStateOutputPath new-directory. Only Enabled on an existing subscription. Disable interrupts collection; enable/save activates it. See docs/wec-state.md.';return} + $arguments=@{Action=$WecStateAction;OutputPath=$WecStateOutputPath} + $map=@{WecStateId='Id';WecStateSourceSid='SourceSids';WecStateDesired='State';WecStatePlanPath='PlanPath';WecStatePlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaWecState @arguments + $report + if($report.ExitCode){exit $report.ExitCode} + } 'wec-update' { if ($Help) {Write-Host 'Usage: wec-update [-WecUpdateAction Plan] -WecUpdateId ID -WecUpdateSourceSid SID -WecUpdateQueryPath query.xml -WecUpdateDescription text -WecUpdateOutputPath new-directory; then Apply with -WecUpdatePlanPath reviewed-plan.json -WecUpdatePlanHash SHA256 -WecUpdateOutputPath new-directory. Only query/description on already disabled subscriptions. See docs/wec-update.md.';return} $arguments=@{Action=$WecUpdateAction;OutputPath=$WecUpdateOutputPath} @@ -2438,6 +2469,14 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] Firewall logging: $_" -ForegroundColor Red; exit 1 } } + 'smb-runtime' { + if ($Help) {Write-Host 'Usage: ./WELA.ps1 smb-runtime [-SmbRuntimeAction Plan|Activate] [-SmbRuntimeOutputPath new-local-directory] [-Auto] [-DryRun]. Activates only six native SMB audit switches; policy and security settings are preserved. See docs/smb-runtime-activation.md.';return} + try { + $report=Invoke-WelaSmbRuntimeActivation -Action $SmbRuntimeAction -OutputPath $SmbRuntimeOutputPath -Auto:$Auto -DryRun:$DryRun + $report + if($report.ExitCode){exit $report.ExitCode} + }catch{Write-Host "[Failed] SMB runtime activation: $_" -ForegroundColor Red;exit 1} + } 'smb-auditing' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 smb-auditing [-SmbAction Audit|Plan|Configure] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/docs/audit-profiles.md b/docs/audit-profiles.md index 68535942..afa8d512 100644 --- a/docs/audit-profiles.md +++ b/docs/audit-profiles.md @@ -88,3 +88,5 @@ For recovery, review the journal and restore the exact prior registry value/type RSoP schema references: [registry policy](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/policy/rsop-registrypolicysetting), [numeric security setting](https://learn.microsoft.com/en-us/previous-versions/aa375064(v=vs.85)), and [security registry value](https://learn.microsoft.com/en-us/previous-versions/aa375052(v=vs.85)). Tests use these actual property shapes; they do not substitute a shared synthetic schema. Targeted file/registry SACL prerequisites are included as a read-only companion plan. See [targeted SACL planning](targeted-sacl-planning.md) for per-user gaps, source distinctions and `-SaclMode Skip`. + +The stronger profile's optional IPsec Main Mode control additionally requires positive local native prerequisite evidence during shared planning/configuration. See [conditional IPsec prerequisites](ipsec-prerequisites.md) for scope, statuses and fresh pre-write checks. diff --git a/docs/ipsec-prerequisites.md b/docs/ipsec-prerequisites.md new file mode 100644 index 00000000..f11af50b --- /dev/null +++ b/docs/ipsec-prerequisites.md @@ -0,0 +1,38 @@ +# Conditional IPsec Main Mode auditing + +The built-in `microsoft-stronger-reviewed-2026-09` profile enables IPsec Main Mode Success and Failure only when the operator selects `-IncludeOptional` **and** WELA observes a positive native prerequisite on the local Windows host. Other profiles and operator-owned custom profile requirements keep their existing meanings. + +```powershell +# Observe the actual local host and retain the evidence in the shared plan. +./WELA.ps1 plan -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -PlanPath ipsec-plan.json + +# Review the complete stronger profile before configuring it: this profile also selects other audit subcategories. +./WELA.ps1 configure -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -DryRun -ResultsPath preview.json +./WELA.ps1 configure -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -Auto -BackupPath new-backup -ResultsPath result.json +``` + +WELA uses the built-in NetSecurity module to read `Get-NetIPsecRule -PolicyStore ActiveStore` and `Get-NetIPsecMainModeSA`. It makes no connection-security, firewall, authentication, service or network changes. The existing configuration engine changes only the selected audit requirements and their advanced-audit precedence prerequisite. + +| Observation | Meaning and conditional configuration behavior | +| --- | --- | +| `Applicable` | Both inventories completed with recognized records, and either an enabled, healthy, non-exemption ActiveStore rule or a current main-mode SA was observed. With explicit optional selection, the audit setting can be assessed/applied. | +| `NotObservedWithinScope` | Both inventories completed, with no qualifying rule or SA. Preserve the audit setting and report `Skipped`, including when the existing mask already equals S+F. This is **not** a claim that all IPsec is unused. | +| `Unknown` | Offline scenario, failed/partial/malformed/duplicate/capped inventory, or an enabled securing rule with uncertain health. A selected configuration control fails without writing that audit setting. Independent profile controls retain their normal behavior. | + +Disabled rules and rules with both `InboundSecurity` and `OutboundSecurity` set to `None` do not establish the prerequisite. Rule names, enabled/security/health values, qualification, association names/endpoints, timestamps, host and separate source outcomes remain in `conditionalPrerequisite` in the plan. Each source is limited to 4096 records; exceeding the limit is Unknown. The inventory is sequential and point-in-time, not an atomic system snapshot. Native calls have the operating system's normal completion behavior; this feature does not impose a wall-clock query timeout. + +An enabled healthy rule in the effective store establishes **configured policy**, not that its address/profile/interface filters currently match traffic, that authentication succeeds, or that any event is emitted. WELA does not inspect the associated filters as an enforcement proof. Absence does not exclude legacy policy, VPN use, other IPsec providers or an idle deployment. Investigate those separately; use a reviewed custom profile if your intended exact audit requirement is independently established outside this automatic scope. + +Offline plans retain Unknown and never query the machine running the planner. Live public `plan`, `audit-settings -Profile` and `configure -Profile` collect only for this built-in stronger-profile condition. A role/build scenario for a different host remains offline. The optional flag is still necessary when positive evidence exists; no extra setting is selected automatically. Offline GPO/Intune exports retain their existing operator-selected deployment semantics and do not claim that endpoint prerequisites have been observed. + +The public configuration runner retains fresh native observations in the control's `PrerequisiteObservations`. It checks before assessment, after the operator prompt and recovery journal immediately before the native policy write, after application and during final verification. Losing the prerequisite after planning or confirmation prevents that write; losing it after a completed write produces a failed verification with the recorded evidence and recovery journal. The direct shared profile executor also checks its selected condition initially and immediately before mutation. No lock prevents concurrent changes after the final check, and no automatic policy rollback is performed. Existing audit recovery procedures still apply. + +The read-only inventory itself requires access to the local native providers; configuration requires elevation. Records can contain policy identifiers and peer IP addresses, so retain exported reports with your other administrator evidence. + +## Validation boundaries + +Portable tests exercise disabled/exempt rules, malformed/failed/capped observations, offline planning, explicit optional selection, source-profile isolation, both configuration paths and prerequisite loss after a prompt. The gated native fixture uses fresh rules between documentation-only IP addresses, exercises the public plan/dry-run/configure commands, and removes its owned rule after confirmation to test native pre-write refusal. It restores all 59 original audit masks, the typed precedence value or its absence, and the original rule inventory. The fixture generates no network traffic or main-mode negotiation. + +Native CI covers Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. Actual SA-positive collection, Windows 11, domain-managed/legacy/VPN scenarios, successful and failed negotiation XML, event volume, collection and detection acceptance remain separate. This advances the prerequisite-detection part of issue #370; it does not close that issue or establish any Sigma eligibility. Sysmon is excluded. + +Sources: Microsoft's [stronger audit recommendations](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/audit-policy-recommendations), [effective IPsec rule inventory and security semantics](https://learn.microsoft.com/en-us/powershell/module/netsecurity/get-netipsecrule?view=windowsserver2025-ps), [current main-mode associations](https://learn.microsoft.com/en-us/powershell/module/netsecurity/get-netipsecmainmodesa?view=windowsserver2025-ps), and [native rule/filter creation semantics](https://learn.microsoft.com/en-us/powershell/module/netsecurity/new-netipsecrule?view=windowsserver2025-ps). diff --git a/docs/smb-auditing.md b/docs/smb-auditing.md index cd600f45..153fa76f 100644 --- a/docs/smb-auditing.md +++ b/docs/smb-auditing.md @@ -30,6 +30,8 @@ Microsoft's Policy CSP pages list **26100.3613** as the availability floor for t ## Policy registry versus effective runtime +The separate explicit [`smb-runtime` activation command](smb-runtime-activation.md) can activate the six native audit Booleans through reviewed SMB setters, with policy-conflict and complete configuration guards. This policy command does not invoke it automatically. Both operations keep event generation and policy persistence separate from current configuration observations. + Reports keep `Policy` (the actual policy-registry value/type) separate from `Runtime` (the corresponding property of `Get-SmbServerConfiguration` or `Get-SmbClientConfiguration`). WELA never substitutes the policy DWORD for a runtime observation: - `Observed`: the getter exposes an actual Boolean. `RuntimeState=Active` means that Boolean was True, not that representative events were generated. False is `NotActive` before the desired policy exists, or `PendingVerification` when the policy registry contains DWORD 1. A correctly written/read-back policy therefore succeeds even when the runtime Boolean remains False. Pending verification does **not** assert propagation delay, a future activation deadline, or that a policy refresh/restart will fix the discrepancy. Its cause and activation timing are unknown; investigate and repeat Audit independently. WELA performs no refresh/restart and never weakens security to make a Boolean change. diff --git a/docs/smb-runtime-activation.md b/docs/smb-runtime-activation.md new file mode 100644 index 00000000..1688e357 --- /dev/null +++ b/docs/smb-runtime-activation.md @@ -0,0 +1,32 @@ +# Explicit native SMB audit activation + +Related to #377. `smb-runtime` explicitly activates the six reviewed native SMB audit switches when their actual runtime Booleans are False. It complements `smb-auditing`, which configures policy DWORDs and reports runtime state separately. Sysmon is excluded. + +```powershell +.\WELA.ps1 smb-runtime +.\WELA.ps1 smb-runtime -SmbRuntimeAction Activate -DryRun +.\WELA.ps1 smb-runtime -SmbRuntimeAction Activate -SmbRuntimeOutputPath C:\Evidence\new-smb-activation -Auto +``` + +The default Plan and Activate dry-run only read. Activate requires a new evidence directory outside the source tree on a local fixed drive with an existing parent. It protects that directory for the actual user, Administrators and SYSTEM. Without `-Auto`, each required change asks for explicit consent. Existing True flags are checked without invoking their setters. Activation requires permissions to use the native SMB configuration cmdlets. + +Only native 64-bit Windows 11 24H2/25H2 (builds 26100/26200) and Server 2025 (26100, including DC product type) are reviewed. Each switch also requires the exact local machine ADMX mapping, genuine Windows `SmbShare` module location, an actual Boolean setter parameter and a native CIM Boolean getter property. Missing definitions, properties, unsupported builds, unreadable values and unexpected configuration types stop the operation. Windows 11 and DC deployment acceptance remain separate from hosted member-server testing. + +| Native command | Only permitted parameters | +| --- | --- | +| `Set-SmbServerConfiguration` | `AuditClientDoesNotSupportEncryption`, `AuditClientDoesNotSupportSigning`, `AuditInsecureGuestLogon` | +| `Set-SmbClientConfiguration` | `AuditServerDoesNotSupportEncryption`, `AuditServerDoesNotSupportSigning`, `AuditInsecureGuestLogon` | + +Every selected value is set to Boolean True, one at a time. The command does not set signing/encryption requirements, enable guest access, modify shares, change services, restart Windows, refresh policy, change channels or generate traffic. It changes no registry-policy value. A current absent policy value is compatible and stays absent; a present policy must be DWORD 1. Any conflicting or malformed policy blocks the entire activation before writes. Absence does not establish local ownership or rule out future GPO/MDM changes. This is an explicit local runtime configuration operation, not a GPO edit or a promise of persistence. + +The plan captures all six typed policy tuples, local ADMX hashes, host/build identity, native module/source fingerprints and every supported property exposed by both native configuration getters. Before each setter, WELA compares the complete current snapshot, writes and flushes a Pending receipt to disk, then checks the snapshot again after any prompt. The only permitted readback difference is that single audit Boolean becoming True. Every other native configuration property and policy tuple must remain unchanged before a Confirmed receipt is written. A final complete readback is required for `RuntimeAuditingActive`. + +The evidence directory retains `plan.json`, numbered Pending/Confirmed receipts and `result.json`. Failure, drift, declined changes or incomplete readback produce a nonzero result. After a failed operation, remaining flags are skipped; earlier successful changes stay recorded. A setter may have changed its flag before throwing or before a receipt failure, so Pending alone is not proof of either success or no change. There is no automatic rollback. Reports and hashes establish observed consistency, not historic authenticity or protection against an administrator replacing the evidence. No atomic lock against concurrent Windows policy/configuration writers is claimed. + +For manual recovery, select one original flag and compare its Pending/Confirmed receipts with fresh native configuration and policy. Restore only that flag's original Boolean through the matching native setter after reviewing concurrent changes and policy authority. Do not replay the entire configuration object or copy getter values into arbitrary setter parameters. Retain the recovery readback separately. Restoring a getter value does not prove the exact historical registry representation or future policy persistence. + +**Runtime activation grants zero Sigma readiness credit.** The command neither generates nor verifies representative SMB events, forwarding, a backend query, guest behavior or persistence after policy refresh. Keep #377 open until its remaining secure-peer event and ingestion acceptance is completed; never weaken signing/encryption or enable guest access solely to manufacture test evidence. + +Focused tests exercise typed configuration, policy conflicts, idempotence, durable-receipt failure, prompt/prewrite/final drift and partial native failures. The explicitly gated disposable GitHub VM fixture prepares only these audit flags as False on Server 2025, invokes the public CLI to activate all six, checks dry-run/idempotence and restores their original native values. It compares every other exposed native configuration property, all policy tuples and source context before/after. Server 2022 tests actual unsupported refusal. Both run under Windows PowerShell 5.1 and PowerShell 7. The fixture performs no SMB traffic or policy changes, and must never run on production. + +Microsoft sources: [SMB client audit parameters](https://learn.microsoft.com/en-us/powershell/module/smbshare/set-smbclientconfiguration?view=windowsserver2025-ps), [SMB server audit parameters](https://learn.microsoft.com/en-us/powershell/module/smbshare/set-smbserverconfiguration?view=windowsserver2025-ps), [signing and encryption audit events](https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-signing-overview), [LanmanServer policy mappings](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-lanmanserver), [LanmanWorkstation policy mappings](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-lanmanworkstation). diff --git a/docs/wec-state.md b/docs/wec-state.md new file mode 100644 index 00000000..e4f09866 --- /dev/null +++ b/docs/wec-state.md @@ -0,0 +1,39 @@ +# Reviewed enable/disable of an existing WEC subscription + +`wec-state` reviews and changes only the **Enabled** Boolean of one existing native source-initiated HTTP subscription to ForwardedEvents. It completes the local pause/resume configuration step around [disabled query updates](wec-update.md). Disabling interrupts collection; enabling and saving activates the subscription. Review the source authorization, query, ReadExistingEvents setting and collection impact before Apply. No subscription is created, replaced or deleted, and no listener, firewall, service, channel, source authorization or query is changed. + +```powershell +# Native 64-bit Windows PowerShell 5.1 or PowerShell 7 on the collector. +./WELA.ps1 wec-state -WecStateId 'Reviewed native subscription' ` + -WecStateSourceSid 'S-1-5-21-111111111-222222222-333333333-1234' ` + -WecStateDesired Disabled -WecStateOutputPath C:\Evidence\disable-plan + +# Review plan.json and record its PlanHash from the planning result. +./WELA.ps1 wec-state -WecStateAction Apply ` + -WecStatePlanPath C:\Evidence\disable-plan\plan.json ` + -WecStatePlanHash '' ` + -WecStateOutputPath C:\Evidence\disable-apply + +# Resuming requires a fresh plan against the current definition: +./WELA.ps1 wec-state -WecStateId 'Reviewed native subscription' ` + -WecStateSourceSid 'S-1-5-21-111111111-222222222-333333333-1234' ` + -WecStateDesired Enabled -WecStateOutputPath C:\Evidence\enable-plan +``` + +Plan is the default and performs read-only native observations plus new evidence files. State is always explicit. Apply requires the reviewed file and separately supplied SHA256. `-Auto`, `-DryRun`, hypothetical host/role overrides and unrelated configuration options are rejected. An already matching state performs no native save: saving an enabled subscription could otherwise reactivate/retry it. + +The actual collector must be a standalone or member Server 2022/2025 with Wecsvc already running. Enabling additionally requires ForwardedEvents already enabled; its observed configuration is included in the review/context guards. Explicit domain source SIDs must match its existing narrow authorization exactly; this does not prove those sources exist or can connect. Supported definitions use the existing strict native subscription parser: exact built-in channel filters, source-initiated HTTP5985, ForwardedEvents, a standard delivery preset, explicit content format/locale and ReadExistingEvents. Certificate/non-domain sources, arbitrary delivery properties and Sysmon/EMET are excluded. Dedicated domain/Kerberos deployment remains a separate [WEF configuration](wef-deployment.md) operation. + +The reviewed plan binds complete original subscription XML, desired Boolean, actual host/build/role and operator identity/logon, service state and implementation hashes. Plan and Apply may run in separate processes in the same Windows logon; a different logon needs a fresh plan. Each operation also compares full native token statistics, including token/modification identifiers, to reject token or privilege changes during that operation. Hashes establish consistency, not authenticated approval or an untrusted evidence author's identity. + +Apply uses `EC_OPEN_EXISTING` and requires the complete current definition to match its reviewed pre-state. A private Pending receipt is flushed and verified before mutation. Immediately before saving it rechecks evidence, source files, host/reader/token/service and full XML; a freshly opened native view also checks Enabled, query, description and authorization. The only property passed to `EcSetSubscriptionProperty` is `EcSubscriptionEnabled`. Readback requires the desired state and every other observed XML element to remain semantically identical, including native Delivery/EventSources expansion. Raw original/after XML is retained without rewriting it. A changing source inventory can therefore leave the configuration result unverified even when the requested Enabled value is observed. + +Windows exposes no subscription lock, generation identity or atomic compare-and-swap. Concurrent administrators, source updates or an identical delete/recreate cannot all be excluded by these observations. Coordinate the operation on a quiescent subscription. The command makes no automatic rollback: reversing a state change requires another reviewed plan against the current definition. Failed saves or differing readback return `SaveAttemptedUnverified`, retaining the native error code and a best-effort post-failure definition/runtime observation. An activation failure can still persist Enabled; failure never implies rollback; retain the pending receipt and inspect actual Windows state before deciding what to do next. `NativeSaveAttempted` records whether the native save call was reached, including its failures. Pre-save refusals do not receive that flag. + +Output must be a new directory under an existing local fixed-drive parent. UNC/device paths, streams and observed reparse points are rejected through the shared evidence-path helper. The new directory is restricted to the operator, SYSTEM and Administrators; existing paths and ACLs remain unchanged. Files use exclusive creation, flushed readback and SHA256 checks before the final manifest. These are sequential observations, not protection against a competing administrator. Reports contain sensitive source/host/account metadata. A missing final manifest means the evidence is incomplete. + +`ReviewRequired`, `AlreadyMatches` and `StateChangedAndVerified` are configuration results. Separate bounded `RuntimeBefore`/`RuntimeAfter` objects reuse [typed native runtime observations](wec-runtime.md), capped at 32 sources; their Unknown/Partial statuses remain visible and do not become healthy-delivery claims. Active, heartbeat or an enabled setting proves neither event arrival nor uninterrupted collection. Bookmark continuity, backlog, transmission latency, source authorization effectiveness, retention and Sigma readiness remain unverified; `ReadyRuleCredit` is always zero. The command does not create an event or refresh a source. + +Portable tests exercise stale plans, wrong hashes/types/authorization, duplicate JSON, unsupported queries, host/token/source drift, false native success, preservation/evidence failures, and idempotence. The gated disposable Server 2022/2025 × Windows PowerShell 5.1/PowerShell 7 fixture creates one uniquely owned subscription authorized to a fictional SID, uses the public CLI for actual enable/disable and idempotent transitions, checks complete preservation and stale-plan refusal, temporarily enables ForwardedEvents as a fixture prerequisite, then removes only the owned subscription and restores exact channel settings plus service state/startup. It creates no listener or real source. Native CI validates local state transitions only; connected Windows 11/member/DC/ADCS sources, actual event arrival, disable/resume gaps and bookmarks remain isolated multi-host acceptance for issue #368. + +References: Microsoft [subscription property types](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/ne-evcoll-ec_subscription_property_id), [existing-only open](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecopensubscription), [access/open constants](https://learn.microsoft.com/en-us/windows/win32/wec/windows-event-collector-constants), [save activation/retry semantics](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecsavesubscription) and [token statistics](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-token_statistics). diff --git a/docs/wec-update.md b/docs/wec-update.md index 80d1b598..9e1bdfc4 100644 --- a/docs/wec-update.md +++ b/docs/wec-update.md @@ -21,7 +21,7 @@ The reviewed plan binds the complete original XML, explicit desired values, actu Concurrent changes, enabled subscriptions, unsupported definitions, denied reads and changed plans fail rather than broadening scope. If save is attempted but fails or readback differs, the manifest says `SaveAttemptedUnverified`; no automatic rollback can overwrite an intervening administrator change. Preserve the receipt and inspect the actual subscription. Restoring original values requires a fresh plan against its current state using the original recorded query/description. Windows exposes no compare-and-swap or subscription lock here: the pre-save checks narrow but cannot eliminate a concurrent administrative write between observation and save. Coordinate a maintenance window; hashes are consistency checks, not signatures or authenticated approval. -The subscription remains disabled, and authorization, destination, delivery, locale, transport, ReadExistingEvents and other observed settings must remain unchanged. This first version deliberately requires disabled state: Microsoft documents that saving an enabled subscription activates it. Active-source delivery and bookmark continuity require separate lab acceptance before extending that scope. A successful disabled update grants **zero Sigma readiness credit** and proves neither delivery nor retention. +The subscription remains disabled, and authorization, destination, delivery, locale, transport, ReadExistingEvents and other observed settings must remain unchanged. This first version deliberately requires disabled state: Microsoft documents that saving an enabled subscription activates it. Use the separate [reviewed Enabled transition](wec-state.md) command to disable or enable an existing subscription. Active-source delivery and bookmark continuity require separate lab acceptance. A successful disabled update grants **zero Sigma readiness credit** and proves neither delivery nor retention. Tests include malformed/duplicate JSON, stale plans, changed context, unexpected enablement, preservation failure, native error, false success, idempotence and pending receipt ordering. Disposable Server 2022/2025 × Windows PowerShell 5.1/PowerShell 7 CI creates one unique disabled subscription with no real source, changes and restores query/description through the public command, rejects the stale plan, verifies other properties and restores subscription inventory plus original Wecsvc state/startup. It does not validate active sources or bookmarks. diff --git a/modules/AuditProfiles.psm1 b/modules/AuditProfiles.psm1 index 65b21903..d99efc4d 100644 --- a/modules/AuditProfiles.psm1 +++ b/modules/AuditProfiles.psm1 @@ -1,6 +1,7 @@ # Requires Windows PowerShell 5.1 or PowerShell 7. No Windows dependency for schema/planning. Set-StrictMode -Version 2.0 . (Join-Path $PSScriptRoot '../scripts/CustomAuditProfiles.ps1') +. (Join-Path $PSScriptRoot '../scripts/IpsecPrerequisites.ps1') function Get-WelaProperty { param($Object, [string]$Name, $Default = $null) @@ -68,7 +69,8 @@ function Get-WelaAuditProfilePlan { [Parameter(Mandatory)][string]$Profile, [Parameter(Mandatory)][ValidateSet('Client', 'MemberServer', 'DomainController', 'ADCS')][string]$Role, [Parameter(Mandatory)][ValidateRange(1, 999999)][int]$Build, - [hashtable]$Current = @{}, [switch]$IncludeOptional, + [hashtable]$Current = @{}, [switch]$IncludeOptional, [switch]$ObserveIpsec, + [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite }, [string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json'), [switch]$CustomFile ) @@ -85,6 +87,10 @@ function Get-WelaAuditProfilePlan { foreach ($property in $selected.controls.PSObject.Properties) { $controls[$property.Name] = $property.Value } $override = Get-WelaProperty $selected.roleOverrides $Role if ($override) { foreach ($property in $override.PSObject.Properties) { $controls[$property.Name] = $property.Value } } + $ipsec = $null + if (-not $CustomFile -and $selected.id -ceq 'microsoft-stronger-reviewed-2026-09') { + $ipsec = if ($ObserveIpsec) { & $ReadIpsec } else { Get-WelaIpsecPrerequisite -Offline } + } $rows = foreach ($policy in $data.catalog) { $control = $controls[$policy.id] $mode = if ($control) { $control.mode } else { 'unchanged' } @@ -102,7 +108,17 @@ function Get-WelaAuditProfilePlan { $compliance = if ($action -eq 'No change') { 'Compliant' } else { 'Drift' } } } + $conditional = $null + if ($ipsec -and $policy.id -eq 'IPsec Main Mode' -and $mode -eq 'optional') { + $conditional = $ipsec + if ($IncludeOptional -and $ipsec.Status -ne 'Applicable') { + $desired = $null + $action = if ($ipsec.Status -eq 'NotObservedWithinScope') { 'Preserve (IPsec not observed in scope)' } else { 'Unknown IPsec prerequisite' } + $compliance = 'Not assessed' + } + } [pscustomobject][ordered]@{ + conditionalPrerequisite = $conditional id = $policy.id; guid = $policy.guid; category = $policy.category; mode = $mode requiredMask = $mask; currentMask = $currentMask; targetMask = $desired recommendation = if ($mode -in @('exact', 'minimum', 'optional')) { "$(Format-WelaAuditMask $mask) [$mode]" } else { $mode } @@ -268,7 +284,8 @@ function Invoke-WelaAuditProfilePlan { [Parameter(Mandatory)]$Plan, [scriptblock]$ReadPolicy = { Get-WelaEffectiveAuditPolicy }, [scriptblock]$WritePolicy, - [scriptblock]$ReadContext = { Get-WelaHostContext } + [scriptblock]$ReadContext = { Get-WelaHostContext }, + [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite } ) if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource } $hostContext = & $ReadContext @@ -277,21 +294,28 @@ function Invoke-WelaAuditProfilePlan { $selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) }) $results = foreach ($policy in $selected) { $initial = $null; $effective = $null; $target = $null; $errorText = $null; $status = 'No change' + $conditional = Test-WelaIpsecConditionalPolicy $Plan $policy; $observations = @(); $skipConditional = $false try { if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource } + if ($conditional) { + $evidence = & $ReadIpsec; $observations += $evidence + if ($evidence.Status -eq 'NotObservedWithinScope') { $status = 'Skipped'; $skipConditional = $true; $errorText = 'IPsec prerequisite not observed within the documented native scope; policy preserved.' } + else { Assert-WelaIpsecPrerequisite $evidence } + } # Whole-plan preflight is not a current-state cache: re-read immediately before each control. $fresh = & $ReadPolicy if ($fresh -isnot [hashtable] -or -not $fresh.ContainsKey($policy.guid) -or $null -eq $fresh[$policy.guid] -or $fresh[$policy.guid] -notin @(0, 1, 2, 3)) { throw 'Current audit policy became unknown before application.' } $initial = $fresh[$policy.guid]; $effective = $initial $isMinimum = $policy.mode -eq 'minimum' $target = if ($isMinimum) { [int]$initial -bor [int]$policy.requiredMask } else { [int]$policy.requiredMask } - if ($initial -ne $target) { + if (-not $skipConditional -and $initial -ne $target) { if ($PSCmdlet.ShouldProcess($policy.id, "Set audit policy to $(Format-WelaAuditMask $target)")) { if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource $freshContext = & $ReadContext if ($freshContext.Role -ne $Plan.role -or $freshContext.Build -ne $Plan.build) { throw 'Custom profile target changed before application.' } } + if ($conditional) { $evidence = & $ReadIpsec; $observations += $evidence; Assert-WelaIpsecPrerequisite $evidence } $writeMode = if ($isMinimum) { 'minimum' } else { 'exact' } if ($WritePolicy) { # Existing two-argument test providers retain their merged-mask contract. @@ -314,6 +338,7 @@ function Invoke-WelaAuditProfilePlan { [pscustomobject]@{ id = $policy.id; guid = $policy.guid; mode = $policy.mode beforeMask = $initial; targetMask = $target; effectiveMask = $effective; status = $status; error = $errorText + prerequisiteObservations = $observations prerequisites = $policy.prerequisites; evidence = $policy.evidence; sourceIds = @($policy.sourceIds) } } @@ -325,4 +350,4 @@ function Invoke-WelaAuditProfilePlan { } } -Export-ModuleMember -Function Import-WelaAuditProfiles, Import-WelaCustomAuditProfiles, Assert-WelaCustomProfileSource, Get-WelaCustomReportPath, Write-WelaCustomProfileReport, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan +Export-ModuleMember -Function Get-WelaIpsecPrerequisite, Assert-WelaIpsecPrerequisite, Test-WelaIpsecConditionalPolicy, Import-WelaAuditProfiles, Import-WelaCustomAuditProfiles, Assert-WelaCustomProfileSource, Get-WelaCustomReportPath, Write-WelaCustomProfileReport, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 3b93b4e6..6e5297fa 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -284,10 +284,17 @@ function Get-WelaAuditPolicyMask { function Set-WelaAuditPolicyControl { param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3, - [ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence) + [ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence, + $IpsecObservations, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite }) $guid = $Policy.GUID - $state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence } - $read = { param($state) Get-WelaAuditPolicyMask -Guid $state.Guid } + $state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence; IpsecObservations=$IpsecObservations; ReadIpsec=$ReadIpsec } + $read = { param($state) + if ($null -ne $state.IpsecObservations) { + $evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence) + Assert-WelaIpsecPrerequisite $evidence + } + Get-WelaAuditPolicyMask -Guid $state.Guid + } $test = { param($value, $state) if ($state.Mode -eq 'minimum') { return ($value -band $state.Mask) -eq $state.Mask } @@ -311,6 +318,11 @@ function Set-WelaAuditPolicyControl { $failure = if ($state.Mask -band 2) { 'enable' } else { 'disable' } $arguments += "/success:$success", "/failure:$failure" } + if ($null -ne $state.IpsecObservations) { + # This check runs after the operator prompt and durable recovery journal. + $evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence) + Assert-WelaIpsecPrerequisite $evidence + } Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments $arguments } Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy ` @@ -318,7 +330,7 @@ function Set-WelaAuditPolicyControl { } function Set-WelaProfileAuditControls { - param($Context, $Plan) + param($Context, $Plan, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite }) if ($Plan.PSObject.Properties['CustomProfileSource']) { $Context | Add-Member NoteProperty CustomProfileGuard ([pscustomobject]@{Source=$Plan.CustomProfileSource;Role=$Plan.role;Build=$Plan.build}) -Force Assert-WelaConfigurationProfileGuard $Context @@ -334,9 +346,26 @@ function Set-WelaProfileAuditControls { $Context.Results.Add([pscustomobject]@{ Id = "AuditPolicy/$($policy.id)"; Kind = 'AuditPolicy'; Target = @{ Guid = $policy.guid }; Desired = $policy.requiredMask; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'Audit precedence was not verified; dependent policy was not changed.' }) continue } - $mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' } - Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence + $conditional = Test-WelaIpsecConditionalPolicy $Plan $policy + $observations = $null; $blocked = $false + if ($conditional) { + $observations = New-Object 'System.Collections.Generic.List[object]' + try { + $evidence = & $ReadIpsec; $observations.Add($evidence) + $blocked = $evidence.Status -ne 'Applicable' + $status = if ($evidence.Status -eq 'NotObservedWithinScope') { 'Skipped' } else { 'Failed' } + $diagnostic = "IPsec prerequisite $($evidence.Status); policy preserved. $($evidence.Diagnostic)" + } catch { $blocked = $true; $status = 'Failed'; $diagnostic = $_.ToString() } + if ($blocked) { + $Context.Results.Add([pscustomobject]@{Id="AuditPolicy/$($policy.id)";Kind='AuditPolicy';Target=@{Guid=$policy.guid};Desired=@{Mask=$policy.requiredMask;Mode='exact'};Before=$null;After=$null;Status=$status;Diagnostic=$diagnostic}) + } + } + if (-not $blocked) { + $mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' } + Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence -IpsecObservations $observations -ReadIpsec $ReadIpsec + } $row = $Context.Results[$Context.Results.Count - 1] + if ($conditional) { $row | Add-Member NoteProperty PrerequisiteObservations $observations } $row | Add-Member NoteProperty Profile $Plan.profile $row | Add-Member NoteProperty Version $Plan.version $row | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256 diff --git a/scripts/IpsecPrerequisites.ps1 b/scripts/IpsecPrerequisites.ps1 new file mode 100644 index 00000000..ef0bc82b --- /dev/null +++ b/scripts/IpsecPrerequisites.ps1 @@ -0,0 +1,73 @@ +# Read-only local NetSecurity evidence. No policy, service or traffic changes. +function Test-WelaIpsecConditionalPolicy { + param($Plan, $Policy) + return (-not $Plan.PSObject.Properties['CustomProfileSource'] -and + $Plan.profile -ceq 'microsoft-stronger-reviewed-2026-09' -and + $Policy.guid -ieq '0CCE9218-69AE-11D9-BED3-505054503030' -and $Policy.mode -eq 'optional') +} + +function Get-WelaIpsecPrerequisite { + [CmdletBinding()] + param([switch]$Offline, + [scriptblock]$ReadRules = { NetSecurity\Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop }, + [scriptblock]$ReadAssociations = { NetSecurity\Get-NetIPsecMainModeSA -ErrorAction Stop }) + $started = [DateTime]::UtcNow.ToString('o') + $rules = @(); $associations = @(); $reads = @(); $diagnostics = @() + if ($Offline) { $diagnostics += 'Offline scenario; this host was not queried.' } + else { + foreach ($source in @('ActiveStoreRules', 'MainModeAssociations')) { + $status = 'Complete'; $errorText = ''; $items = @() + try { + $reader = if ($source -eq 'ActiveStoreRules') { $ReadRules } else { $ReadAssociations } + # Keep at most 4096 observations per native source. A cap is not an empty/successful inventory. + $items = @(& $reader | Select-Object -First 4097) + if ($items.Count -gt 4096) { throw 'Observation cap exceeded (4096 records).' } + $seen = @{} + foreach ($item in $items) { + if ($source -eq 'ActiveStoreRules') { + foreach ($property in @('Name', 'Enabled', 'InboundSecurity', 'OutboundSecurity', 'PrimaryStatus')) { + if ($null -eq $item -or -not $item.PSObject.Properties[$property] -or $null -eq $item.$property) { throw "Missing native rule property: $property." } + } + $name = [string]$item.Name + $enabled = [string]$item.Enabled; $inbound = [string]$item.InboundSecurity; $outbound = [string]$item.OutboundSecurity; $health = [string]$item.PrimaryStatus + if (-not $name -or $name.Length -gt 1024 -or $seen.ContainsKey($name) -or $enabled -cnotin @('True','False') -or + $inbound -cnotin @('None','Request','Require') -or $outbound -cnotin @('None','Request','Require') -or + $health -cnotin @('OK','Inactive','Error','Unknown')) { throw "Unrecognized or duplicate native IPsec rule observation: Name='$name', Enabled='$enabled', InboundSecurity='$inbound', OutboundSecurity='$outbound', PrimaryStatus='$health'." } + $seen[$name] = $true + $qualifies = $enabled -ceq 'True' -and ($inbound -cne 'None' -or $outbound -cne 'None') -and $health -ceq 'OK' + $rules += [pscustomobject]@{ Name=$name; Enabled=$enabled; InboundSecurity=$inbound; OutboundSecurity=$outbound; PrimaryStatus=$health; Qualifies=$qualifies } + if ($enabled -ceq 'True' -and ($inbound -cne 'None' -or $outbound -cne 'None') -and $health -cne 'OK') { throw 'Enabled non-exemption rule has uncertain effective health.' } + } else { + foreach ($property in @('Name','LocalEndpoint','RemoteEndpoint')) { + if ($null -eq $item -or -not $item.PSObject.Properties[$property] -or -not [string]$item.$property) { throw "Missing native association property: $property." } + } + $name = [string]$item.Name; $local = [string]$item.LocalEndpoint; $remote = [string]$item.RemoteEndpoint + $address = $null + if ($name.Length -gt 1024 -or $seen.ContainsKey($name) -or -not [Net.IPAddress]::TryParse($local,[ref]$address) -or -not [Net.IPAddress]::TryParse($remote,[ref]$address)) { throw 'Unrecognized or duplicate main-mode association.' } + $seen[$name] = $true + $associations += [pscustomobject]@{ Name=$name; LocalEndpoint=$local; RemoteEndpoint=$remote } + } + } + } catch { $status = 'Unknown'; $errorText = $_.Exception.Message; $diagnostics += "$source`: $errorText" } + $reads += [pscustomobject]@{ Source=$source; Status=$status; ObservedCount=$items.Count; Diagnostic=$errorText } + } + } + $status = if ($Offline -or @($reads | Where-Object Status -ne Complete).Count) { 'Unknown' } + elseif (@($rules | Where-Object Qualifies).Count -or $associations.Count) { 'Applicable' } + else { 'NotObservedWithinScope' } + [pscustomobject][ordered]@{ + SchemaVersion=1; Status=$status; Scope='Local NetSecurity ActiveStore rules and current main-mode SAs' + StartedUtc=$started; CompletedUtc=[DateTime]::UtcNow.ToString('o'); ComputerName=$env:COMPUTERNAME + Basis=$(if ($status -eq 'Applicable') { 'Enabled healthy non-exemption effective rule or current main-mode SA observed.' } else { 'No complete positive prerequisite evidence.' }) + Reads=$reads; Rules=$rules; MainModeAssociations=$associations; Diagnostic=($diagnostics -join ' ') + Limitations='Point-in-time local scope. Configured rules do not prove matching traffic, successful negotiation or audit events. Absence does not exclude legacy IPsec, VPN or other providers. No event-volume, failure-outcome or Sigma credit.' + } +} + +function Assert-WelaIpsecPrerequisite { + param($Evidence) + if ($null -eq $Evidence -or $Evidence.Status -cne 'Applicable') { + $status = if ($Evidence) { $Evidence.Status } else { 'Unknown' } + throw "IPsec Main Mode prerequisite is $status; this conditional audit setting was not changed. $($Evidence.Diagnostic)" + } +} diff --git a/scripts/SmbRuntimeActivation.ps1 b/scripts/SmbRuntimeActivation.ps1 new file mode 100644 index 00000000..2663d1d7 --- /dev/null +++ b/scripts/SmbRuntimeActivation.ps1 @@ -0,0 +1,193 @@ +# Explicit native audit-switch activation. No registry policy, security, share or service writes. +function Get-WelaSmbRuntimeKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress } + +function Get-WelaSmbRuntimeSources { + $result=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/SmbRuntimeActivation.ps1','scripts/SmbAuditing.ps1','scripts/Configuration.ps1','scripts/WefArrival.ps1')) { + $result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash + } + [pscustomobject]$result +} + +function Assert-WelaSmbRuntimeCommand { + param($Command,[ValidateSet('Server','Client')][string]$Side,[ValidateSet('Get','Set')][string]$Verb,[string]$ModuleBase) + # SmbShare exports functions from these native nested CDXML modules. + if($Command.Name -cne "$Verb-Smb${Side}Configuration" -or $Command.ModuleName -cne "Smb${Side}Configuration" -or + [string]$Command.CommandType -cne 'Function' -or [IO.Path]::GetFullPath($Command.Module.ModuleBase) -ine $ModuleBase){ + $observed=[pscustomobject]@{Name=$Command.Name;ModuleName=$Command.ModuleName;ModuleBase=$Command.Module.ModuleBase;Type=[string]$Command.CommandType} + throw "SMB commands must resolve to the reviewed native SmbShare CDXML module. Expected $ModuleBase; observed $(Get-WelaSmbRuntimeKey $observed)" + } + if($Verb -eq 'Set') { + $component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'} + foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component)) { + if(-not $Command.Parameters.ContainsKey($definition.Name) -or $Command.Parameters[$definition.Name].ParameterType -ne [bool]) { + throw "Native setter lacks the exact Boolean parameter $($definition.Name)." + } + } + } +} + +function Get-WelaSmbRuntimeCommands { + $base=[IO.Path]::GetFullPath((Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/Modules/SmbShare')) + $commands=[ordered]@{} + foreach($side in @('Server','Client')) { + foreach($verb in @('Get','Set')) { + $name="SmbShare\$verb-Smb${side}Configuration" + $found=@(Get-Command -Name $name -ErrorAction Stop) + if($found.Count -ne 1){throw 'Expected exactly one native module-qualified SMB command.'} + Assert-WelaSmbRuntimeCommand -Command $found[0] -Side $side -Verb $verb -ModuleBase $base + $commands[$name]=[pscustomobject]@{ModuleName=$found[0].ModuleName;ModuleBase=$base;ModuleVersion=$found[0].Module.Version.ToString();CommandType=$found[0].CommandType.ToString()} + } + } + $files=@(Get-ChildItem -LiteralPath $base -File -Recurse -ErrorAction Stop | Where-Object Extension -in @('.psd1','.psm1','.cdxml','.dll','.ps1xml') | Sort-Object FullName) + if($files.Count -lt 1 -or $files.Count -gt 100){throw 'Unexpected native SMB module inventory.'} + $hashes=[ordered]@{} + foreach($file in $files){ + if($file.Length -gt 16MB -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unsupported SMB module source.'} + $hashes[$file.FullName]=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 -ErrorAction Stop).Hash + } + [pscustomobject]@{Commands=[pscustomobject]$commands;Files=[pscustomobject]$hashes} +} + +function ConvertTo-WelaSmbRuntimeConfiguration { + param($Configuration,[ValidateSet('Server','Client')][string]$Side) + if($Configuration.CimClass.CimClassName -cne "MSFT_Smb${Side}Configuration"){throw 'Expected one actual native SMB configuration CIM instance.'} + $properties=@($Configuration.CimInstanceProperties | Sort-Object Name) + if($properties.Count -lt 3 -or $properties.Count -gt 160){throw 'Unexpected SMB configuration property count.'} + $result=[ordered]@{} + foreach($property in $properties) { + if($result.Contains($property.Name)){throw 'Duplicate SMB configuration property.'} + $value=$property.Value + foreach($item in @($value)) { + if($null -ne $item -and $item -isnot [bool] -and $item -isnot [string] -and + $item -isnot [byte] -and $item -isnot [uint16] -and $item -isnot [uint32] -and $item -isnot [uint64] -and + $item -isnot [int16] -and $item -isnot [int32] -and $item -isnot [int64]){throw "Unsupported native configuration value: $($property.Name)"} + if($item -is [string] -and $item.Length -gt 8192){throw 'Native configuration string exceeds bound.'} + } + if(@($value).Count -gt 128){throw 'Native configuration array exceeds bound.'} + $result[$property.Name]=[pscustomobject]@{CimType=$property.CimType.ToString();Value=$value} + } + $component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'} + foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component)) { + if(-not $result.Contains($definition.Name) -or $result[$definition.Name].Value -isnot [bool] -or $result[$definition.Name].CimType -cne 'Boolean') { + throw "Native getter lacks the exact Boolean property $($definition.Name)." + } + } + [pscustomobject]$result +} + +function Get-WelaSmbRuntimeState { + $hostState=Get-WelaSmbAuditHost + if($hostState.Status -ne 'Candidate'){throw "SMB runtime activation is $($hostState.Status): $($hostState.Diagnostic)"} + $commands=Get-WelaSmbRuntimeCommands + $policies=[ordered]@{} + foreach($definition in Get-WelaSmbAuditDefinitions) { + $capability=Get-WelaSmbAuditCapability -Definition $definition -HostState $hostState + if($capability.Status -ne 'Supported'){throw "Unverified $($definition.Component)/$($definition.Name): $($capability.Diagnostic)"} + $policies["$($definition.Component)/$($definition.Name)"]=[pscustomobject]@{ + Path=$definition.Path;Name=$definition.Name;AdmxSha256=$capability.AdmxSha256 + Policy=Get-WelaRegistryState -Path $definition.Path -Name $definition.Name + } + } + $configurations=[ordered]@{} + foreach($side in @('Server','Client')) { + $command="SmbShare\Get-Smb${side}Configuration" + $native=@(& $command -ErrorAction Stop) + if($native.Count -ne 1){throw 'Expected exactly one native SMB configuration.'} + $configurations[$side]=ConvertTo-WelaSmbRuntimeConfiguration -Configuration $native[0] -Side $side + } + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Commands=$commands;Sources=Get-WelaSmbRuntimeSources;Policies=[pscustomobject]$policies;Configurations=[pscustomobject]$configurations} +} + +function Get-WelaSmbRuntimePlan { + param($State) + foreach($definition in Get-WelaSmbAuditDefinitions) { + $id="$($definition.Component)/$($definition.Name)" + $policy=$State.Policies.$id.Policy + $side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'} + $value=$State.Configurations.$side.($definition.Name).Value + $compatible=($policy.ValueExists -is [bool] -and -not $policy.ValueExists) -or + ($policy.ValueExists -eq $true -and $policy.Type -ceq 'DWord' -and + ($policy.Value -is [int] -or $policy.Value -is [long] -or $policy.Value -is [uint32]) -and $policy.Value -eq 1) + [pscustomobject][ordered]@{Id=$id;Side=$side;Name=$definition.Name;Before=$value;Desired=$true;Policy=$policy + Status=$(if(-not $compatible){'BlockedPolicy'}elseif($value){'AlreadyActive'}else{'ActivationRequired'}) + Diagnostic=$(if(-not $compatible){'Existing policy is not absent or DWORD 1; review its authority. It will not be overwritten.'}elseif($policy.ValueExists){'Policy DWORD 1 and runtime Boolean are separate observations.'}else{'Policy value is absent; explicit activation changes native local configuration only.'})} + } +} + +function Set-WelaSmbRuntimeFlag { + param([string]$Id) + $matches=@(Get-WelaSmbAuditDefinitions | Where-Object {"$($_.Component)/$($_.Name)" -ceq $Id}) + if($matches.Count -ne 1){throw 'Unknown SMB audit switch.'} + $definition=$matches[0] + $side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'} + $command="SmbShare\Set-Smb${side}Configuration" + $parameters=@{Confirm=$false;Force=$true;ErrorAction='Stop'} + $parameters[$definition.Name]=$true + $null=& $command @parameters +} + +function Write-WelaSmbRuntimeReceipt { + param([string]$Root,[string]$Name,$Value) + if($Name -notmatch '^(plan|result|[1-6]-(pending|confirmed))\.json$'){throw 'Unexpected receipt filename.'} + $null=Resolve-WelaArrivalPath $Root + $path=Join-Path $Root $Name + $bytes=[Text.UTF8Encoding]::new($false).GetBytes((Get-WelaSmbRuntimeKey $Value)) + if($bytes.Length -gt 4MB){throw 'SMB activation receipt exceeds bound.'} + $stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} + $expected=Get-WelaArrivalHash $bytes + if((Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $expected){throw 'SMB receipt readback differs.'} + [pscustomobject]@{Name=$Name;Bytes=$bytes.Length;Sha256=$expected} +} + +function Invoke-WelaSmbRuntimeActivation { + param([ValidateSet('Plan','Activate')][string]$Action='Plan',[string]$OutputPath,[switch]$Auto,[switch]$DryRun) + if($DryRun -and $Action -ne 'Activate'){throw 'DryRun requires SmbRuntimeAction Activate.'} + if($Action -eq 'Plan' -and ($Auto -or $OutputPath)){throw 'Plan reads only; Auto and OutputPath apply to Activate.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaSmbRuntimeActivation';Action=$Action;DryRun=[bool]$DryRun;RecordedUtc=[DateTime]::UtcNow.ToString('o') + Status='Unverified';ExitCode=1;Before=$null;After=$null;Controls=@();Results=@();Artifacts=@();OutputPath=$null;Diagnostic='' + VerificationScope='Native local audit switches at the recorded observations; policy authority and persistence are unknown';ReadyRuleCredit=0;EventGeneration='Not tested';Forwarding='Not tested'} + try { + $state=Get-WelaSmbRuntimeState;$report.Before=$state + $report.Controls=@(Get-WelaSmbRuntimePlan $state) + if(@($report.Controls | Where-Object Status -eq BlockedPolicy).Count){throw 'One or more policy values conflict or are malformed. No audit flags were changed.'} + if($Action -eq 'Plan' -or $DryRun){$report.Status=if($DryRun){'DryRun'}else{'Planned'};$report.ExitCode=0;return $report} + if(-not $OutputPath){throw 'Activate requires a new SmbRuntimeOutputPath on a local fixed drive.'} + $output=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot;$report.OutputPath=$output + $report.Artifacts+=Write-WelaSmbRuntimeReceipt $output 'plan.json' ([pscustomobject]@{State=$state;Controls=$report.Controls}) + $expectedKey=Get-WelaSmbRuntimeKey $state + $index=0;$stopped=$false + foreach($control in $report.Controls) { + $index++ + $row=[pscustomobject][ordered]@{Id=$control.Id;Before=$control.Before;After=$null;Status='Skipped';Diagnostic='';PendingReceipt=$null;ConfirmedReceipt=$null} + $report.Results+= $row + if($stopped){$row.Diagnostic='A prior activation failed; no further changes were attempted.';continue} + try { + $fresh=Get-WelaSmbRuntimeState + if((Get-WelaSmbRuntimeKey $fresh) -cne $expectedKey){throw 'Host, source, policy or native configuration drifted after the snapshot.'} + if($control.Before){$row.After=$true;$row.Status='AlreadyActive';continue} + if(-not $Auto -and (Read-Host "Activate only SMB audit flag $($control.Id)? (y/N)") -cnotin @('y','Y')){$row.Diagnostic='Declined by operator.';continue} + $row.PendingReceipt=Write-WelaSmbRuntimeReceipt $output "$index-pending.json" ([pscustomobject]@{Kind='Pending';Id=$control.Id;Before=$fresh;Desired=$true;RecordedUtc=[DateTime]::UtcNow.ToString('o')}) + # Re-read after interaction and durable intent, immediately before the setter. + if((Get-WelaSmbRuntimeKey (Get-WelaSmbRuntimeState)) -cne $expectedKey){throw 'Context drifted before the native setter; activation refused.'} + Set-WelaSmbRuntimeFlag -Id $control.Id + $after=Get-WelaSmbRuntimeState;$row.After=$after.Configurations.($control.Side).($control.Name).Value + # The only permitted delta is this one Boolean. All policies and every + # other native configuration property (including security) must match. + $next=Get-WelaSmbRuntimeKey $fresh | ConvertFrom-Json + $next.Configurations.($control.Side).($control.Name).Value=$true + if((Get-WelaSmbRuntimeKey $after) -cne (Get-WelaSmbRuntimeKey $next)){throw 'Native readback did not show exactly the requested audit-only delta.'} + $row.ConfirmedReceipt=Write-WelaSmbRuntimeReceipt $output "$index-confirmed.json" ([pscustomobject]@{Kind='Confirmed';Id=$control.Id;Pending=$row.PendingReceipt;After=$after;RecordedUtc=[DateTime]::UtcNow.ToString('o')}) + $state=$after;$expectedKey=Get-WelaSmbRuntimeKey $state + $row.Status='Activated';$row.Diagnostic='Native Boolean True observed; policy tuple and all other configuration properties preserved.' + }catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message;$stopped=$true} + } + $report.After=Get-WelaSmbRuntimeState + if((Get-WelaSmbRuntimeKey $report.After) -cne $expectedKey){throw 'Final context differs from the last verified configuration. Review partial receipts; no automatic rollback is attempted.'} + if(@($report.Results | Where-Object Status -notin @('Activated','AlreadyActive')).Count){throw 'Some flags were not activated. Inspect per-control results and receipts.'} + $report.Status='RuntimeAuditingActive';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + if($report.OutputPath){$null=Write-WelaSmbRuntimeReceipt $report.OutputPath 'result.json' $report} + $report +} diff --git a/scripts/WecState.ps1 b/scripts/WecState.ps1 new file mode 100644 index 00000000..5db19bf2 --- /dev/null +++ b/scripts/WecState.ps1 @@ -0,0 +1,139 @@ +# Reviewed, existing-only Enabled changes; runtime observations are separate evidence. +function Initialize-WelaWecStateNative { + $path=Join-Path $PSScriptRoot 'WecStateNative.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash + if(-not('Wela.WecState.Edit' -as [type])){Add-Type -Path $path -ErrorAction Stop;$script:WelaWecStateNativeHash=$hash} + if($script:WelaWecStateNativeHash -cne $hash){throw 'Loaded native state setter differs from source; start a fresh process.'} +} +function Get-WelaWecStateContext { + $context=Get-WelaWecUpdateContext + Initialize-WelaWecStateNative + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try {$context.Reader=[ordered]@{Name=$identity.Name;Sid=$identity.User.Value;AuthenticationType=$identity.AuthenticationType;ImpersonationLevel=[string]$identity.ImpersonationLevel;Groups=@($identity.Groups.Value|Sort-Object);TokenStatistics=[Wela.WecState.Edit]::TokenKey($identity.Token)}}finally{$identity.Dispose()} + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents') + try {$context|Add-Member NoteProperty DestinationLog ([ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;Mode=[string]$channel.LogMode;MaximumBytes=$channel.MaximumSizeInBytes;Path=$channel.LogFilePath;SecurityDescriptor=$channel.SecurityDescriptor})}finally{$channel.Dispose()} + $context +} +function Get-WelaWecStateReviewKey { + param($Context) + # Separate CLI invocations can hold different token objects in the same logon. + # Bind plan/apply to the actual logon, and compare complete token statistics + # within each operation to reject privilege or token changes during writes. + $copy=$Context|ConvertTo-Json -Depth 16 -Compress|ConvertFrom-Json + $copy.Reader.TokenStatistics=$Context.Reader.TokenStatistics.Substring(16,16) + $copy|ConvertTo-Json -Depth 16 -Compress +} +function Get-WelaWecStateSources { + $root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{} + foreach($name in @('scripts/WecState.ps1','scripts/WecStateNative.cs','scripts/WecUpdate.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionXml.cs','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','scripts/WecRuntime.ps1','scripts/WecRuntimeNative.cs')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + $sources|ConvertTo-Json -Compress +} +function Get-WelaWecStateDefinition { + param([string]$Xml,[string[]]$SourceSids) + $model=ConvertFrom-WelaWefSubscription -Xml $Xml -SourceSids $SourceSids -Observed + $doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$whole=Get-WelaWefXmlKey $root + $ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$root.NamespaceURI) + $null=$root.RemoveChild($root.SelectSingleNode('s:Enabled',$ns)) + [pscustomobject]@{Id=$model.Id;Xml=$Xml;WholeKey=$whole;PreservedKey=(Get-WelaWefXmlKey $root);Enabled=$model.Definition.Enabled;QueryKey=$model.Query.Key;Description=$model.Definition.Description;SourceAuthorization=$model.Definition.SourceAuthorization} +} +function Read-WelaWecStateDefinition { + param([string]$Id,[string[]]$SourceSids) + if($Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$'){throw 'Invalid exact subscription ID.'} + $definition=Get-WelaWecStateDefinition (Read-WelaWecSubscriptionXml $Id) $SourceSids + if($definition.Id -cne $Id){throw 'Native subscription identity differs from the selected ID.'} + $definition +} +function New-WelaWecStateEdit { + param($Before) + Initialize-WelaWecStateNative + $edit=[Wela.WecState.Edit]::new($Before.Id) + try { + if($edit.OriginalEnabled -ne $Before.Enabled -or (ConvertFrom-WelaWefQuery $edit.OriginalQuery).Key -cne $Before.QueryKey -or $edit.OriginalDescription -cne $Before.Description -or $edit.OriginalAuthorization -cne $Before.SourceAuthorization){throw 'Native handle state differs from the reviewed definition.'} + $edit + }catch{$edit.Dispose();throw} +} +function Assert-WelaWecStatePlan { + param($Plan) + Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Id','SourceSids','ContextKey','Sources','BeforeXml','DesiredEnabled','RecordedUtc') + if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -cne 'WelaWecStatePlan' -or $Plan.Id -isnot [string] -or $Plan.Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$' -or $Plan.SourceSids -isnot [array] -or $Plan.ContextKey -isnot [string] -or $Plan.Sources -isnot [string] -or $Plan.BeforeXml -isnot [string] -or $Plan.DesiredEnabled -isnot [bool]){throw 'Unknown or mistyped state plan.'} + $null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc + foreach($sid in $Plan.SourceSids){if($sid -isnot [string]){throw 'Source SID must be a string.'}} + $null=Get-WelaWefAuthorization $Plan.SourceSids + $before=Get-WelaWecStateDefinition $Plan.BeforeXml $Plan.SourceSids + if($before.Id -cne $Plan.Id){throw 'Plan identity contradicts its original subscription.'} +} +function Read-WelaWecStateRuntime { + param([string]$Id) + try {Get-WelaWecRuntime -Id $Id -MaximumSources 32} + catch {[pscustomobject]@{Status='Unknown';Diagnostic=$_.Exception.Message;ReadyRuleCredit=0}} +} +function Assert-WelaWecStateArtifacts { + param([string]$Root,$Artifacts) + foreach($artifact in $Artifacts){if((Get-FileHash -LiteralPath (Join-Path $Root $artifact.Name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved state evidence changed before completion.'}} +} +function Invoke-WelaWecState { + param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Id,[string[]]$SourceSids,[ValidateSet('Enabled','Disabled')][string]$State,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if(-not $Id -or -not $SourceSids -or -not $State -or $PlanPath -or $PlanHash){throw 'Plan requires exact ID, explicit source SIDs, Enabled or Disabled state and new output; no prior plan.'} + $sourceInput=$null;$sourcePath=Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts' + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $Id -or $SourceSids -or $State){throw 'Apply accepts only a reviewed plan path, its SHA256 and new output.'} + $sourceInput=Read-WelaWecUpdateFile $PlanPath;$sourcePath=$sourceInput.Path + } + $output=New-WelaArrivalOutput $OutputPath $sourcePath + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecState';Action=$Action;Status='Refused';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');OutputPath=$output;PlanHash=$null;BeforeEnabled=$null;DesiredEnabled=$null;NativeSaveAttempted=$false;NativeErrorCode=$null;After=$null;RuntimeBefore=$null;RuntimeAfter=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Delivery='Not established';BookmarkContinuity='Not established';Scope='Only Enabled on one existing native source-initiated subscription. Disable interrupts collection; enable/save activates it. No listener, firewall, service or authorization changes. Sysmon excluded.'} + $edit=$null;$plan=$null;$before=$null + try { + $context=Get-WelaWecStateContext;$contextKey=$context|ConvertTo-Json -Depth 16 -Compress;$sources=Get-WelaWecStateSources + if($Action -eq 'Plan'){ + $before=Read-WelaWecStateDefinition $Id $SourceSids + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecStatePlan';Id=$Id;SourceSids=@($SourceSids);ContextKey=(Get-WelaWecStateReviewKey $context);Sources=$sources;BeforeXml=$before.Xml;DesiredEnabled=($State -eq 'Enabled');RecordedUtc=[DateTime]::UtcNow.ToString('o')} + Assert-WelaWecStatePlan $plan + if($plan.DesiredEnabled -and -not $context.DestinationLog.Enabled){throw 'ForwardedEvents must already be enabled before planning activation; no channel changes are made.'} + $report.RuntimeBefore=Read-WelaWecStateRuntime $Id + if((Read-WelaWecStateDefinition $Id $SourceSids).WholeKey -cne $before.WholeKey -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Get-WelaWecStateSources) -cne $sources){throw 'Host, reader, implementation or subscription drift during planning.'} + $planText=$plan|ConvertTo-Json -Depth 20 + if([Text.Encoding]::UTF8.GetByteCount($planText) -gt 4194304){throw 'Reviewed plan exceeds the four-MiB apply limit.'} + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' $planText;$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired' + }else{ + if($sourceInput.Hash -cne $PlanHash){throw 'Reviewed plan hash differs from the selected file bytes.'} + $plan=ConvertFrom-WelaArrivalJson $sourceInput.Text;Assert-WelaWecStatePlan $plan;$report.PlanHash=$sourceInput.Hash + if($plan.DesiredEnabled -and -not $context.DestinationLog.Enabled){throw 'ForwardedEvents must already be enabled before activation; no channel changes are made.'} + if($plan.ContextKey -cne (Get-WelaWecStateReviewKey $context) -or $plan.Sources -cne $sources){throw 'Actual host/reader/token/service or implementation sources differ from the reviewed plan.'} + $before=Get-WelaWecStateDefinition $plan.BeforeXml $plan.SourceSids +$report.BeforeEnabled=$before.Enabled;$report.DesiredEnabled=$plan.DesiredEnabled + $report.RuntimeBefore=Read-WelaWecStateRuntime $plan.Id + if((Read-WelaWecStateDefinition $plan.Id $plan.SourceSids).WholeKey -cne $before.WholeKey){throw 'Current subscription differs from the reviewed complete definition.'} + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $sourceInput.Text + if($before.Enabled -eq $plan.DesiredEnabled){$report.Status='AlreadyMatches'}else{ + $edit=New-WelaWecStateEdit $before + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-save.json' ([ordered]@{Status='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');Context=$context;BeforeXml=$before.Xml;DesiredEnabled=$plan.DesiredEnabled;PlanHash=$sourceInput.Hash}|ConvertTo-Json -Depth 20) + Assert-WelaWecStateArtifacts $output $report.Artifacts + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaWecStateSources) -cne $sources -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Read-WelaWecStateDefinition $plan.Id $plan.SourceSids).WholeKey -cne $before.WholeKey){throw 'Plan, code, context or complete subscription changed immediately before save.'} + try {$edit.Save($plan.DesiredEnabled)}finally{$report.NativeSaveAttempted=[bool]$edit.SaveAttempted} + $report.Status='SavedAwaitingReadback' + } + $report.RuntimeAfter=Read-WelaWecStateRuntime $plan.Id + $after=Read-WelaWecStateDefinition $plan.Id $plan.SourceSids;$report.After=$after + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.xml' $after.Xml + if($after.Enabled -ne $plan.DesiredEnabled -or $after.PreservedKey -cne $before.PreservedKey -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Get-WelaWecStateSources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Readback, preserved configuration, context, plan or implementation differs after operation.'} + if($report.NativeSaveAttempted){$report.Status='StateChangedAndVerified'} + } + $report.BeforeEnabled=$before.Enabled;$report.DesiredEnabled=$plan.DesiredEnabled + Assert-WelaWecStateArtifacts $output $report.Artifacts + $report.ExitCode=0 + }catch{ + $report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message + $errorObject=$_.Exception + while($errorObject){if($errorObject -is [ComponentModel.Win32Exception]){$report.NativeErrorCode=$errorObject.NativeErrorCode;break};$errorObject=$errorObject.InnerException} + if($report.NativeSaveAttempted -and $plan){ + # A failed activation can still persist Enabled. Never imply rollback. + $report.RuntimeAfter=Read-WelaWecStateRuntime $plan.Id + try {$report.After=Read-WelaWecStateDefinition $plan.Id $plan.SourceSids;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failed-after.xml' $report.After.Xml} + catch {$report.Diagnostic+=' Final definition unavailable: '+$_.Exception.Message} + } + } + finally{if($edit){$edit.Dispose()}} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 32) + $report +} diff --git a/scripts/WecStateNative.cs b/scripts/WecStateNative.cs new file mode 100644 index 00000000..4e5120ea --- /dev/null +++ b/scripts/WecStateNative.cs @@ -0,0 +1,72 @@ +// Existing-only native WEC Enabled setter. No create/delete or other setters. +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +namespace Wela.WecState { + public sealed class Edit : IDisposable { + [StructLayout(LayoutKind.Explicit, Size=16)] struct Variant { + [FieldOffset(0)] public int Boolean; [FieldOffset(8)] public uint Count; [FieldOffset(12)] public uint Type; + } + [DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr EcOpenSubscription(string name,uint access,uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcGetSubscriptionProperty(IntPtr handle,int property,uint flags,uint size,IntPtr value,out uint used); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSetSubscriptionProperty(IntPtr handle,int property,uint flags,ref Variant value); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSaveSubscription(IntPtr handle,uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle); + [DllImport("advapi32.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool GetTokenInformation(IntPtr token,int information,IntPtr buffer,int size,out int used); + IntPtr handle; readonly string name,oldQuery,oldDescription,oldAuthorization; readonly bool oldEnabled; + public bool OriginalEnabled {get{return oldEnabled;}} + public string OriginalQuery {get{return oldQuery;}} + public string OriginalDescription {get{return oldDescription;}} + public string OriginalAuthorization {get{return oldAuthorization;}} + public bool SaveAttempted {get;private set;} + // TOKEN_STATISTICS: TokenId, AuthenticationId and ModifiedId, plus token type. + public static string TokenKey(IntPtr token) { + IntPtr buffer=Marshal.AllocHGlobal(56); + try {int used;if(!GetTokenInformation(token,10,buffer,56,out used))throw new Win32Exception(Marshal.GetLastWin32Error());if(used!=56)throw new InvalidOperationException("Unexpected TOKEN_STATISTICS size."); + byte[] bytes=new byte[56];Marshal.Copy(buffer,bytes,0,bytes.Length);return BitConverter.ToString(bytes).Replace("-",""); + }finally{Marshal.FreeHGlobal(buffer);} + } + static object Read(IntPtr h,int property) { + uint size=16; + for(int attempt=0;attempt<3;attempt++) { + IntPtr buffer=Marshal.AllocHGlobal((int)size); + try { + uint used;bool ok=EcGetSubscriptionProperty(h,property,0,size,buffer,out used);int error=Marshal.GetLastWin32Error(); + if(!ok){if(error!=122)throw new Win32Exception(error);if(used<=size||used>1048576)throw new InvalidOperationException("Invalid native property buffer size.");size=used;continue;} + if(used<16||used>size)throw new InvalidOperationException("Invalid native property length."); + int type=Marshal.ReadInt32(buffer,12); + if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;} + if(type==0&&property==6)return ""; + if(type!=4)throw new InvalidOperationException("Expected scalar native string."); + IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64(); + if(pointer==IntPtr.Zero||offset<16||offset>used-2)throw new InvalidOperationException("Native string pointer is outside its buffer."); + StringBuilder text=new StringBuilder(); + for(int i=0;i<524288&&offset+2L*i+2<=used;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);} + throw new InvalidOperationException("Unterminated native string."); + }finally{Marshal.FreeHGlobal(buffer);} + } + throw new InvalidOperationException("Native property changed repeatedly."); + } + void Check(IntPtr h) { + if((bool)Read(h,0)!=oldEnabled||!String.Equals((string)Read(h,10),oldQuery,StringComparison.Ordinal)||!String.Equals((string)Read(h,6),oldDescription,StringComparison.Ordinal)||!String.Equals((string)Read(h,31),oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Native enabled/query/description/authorization changed since review."); + } + public Edit(string id) { + if(String.IsNullOrWhiteSpace(id)||id.Length>128||id.IndexOf('\0')>=0)throw new ArgumentException("Invalid subscription ID."); + name=id;handle=EcOpenSubscription(name,3,2);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try{oldEnabled=(bool)Read(handle,0);oldQuery=(string)Read(handle,10);oldDescription=(string)Read(handle,6);oldAuthorization=(string)Read(handle,31);}catch{Dispose();throw;} + } + public void Save(bool enabled) { + if(handle==IntPtr.Zero)throw new ObjectDisposedException("Edit"); + if(SaveAttempted)throw new InvalidOperationException("A native edit may be saved only once."); + if(enabled==oldEnabled)throw new InvalidOperationException("Idempotent state must not save or reactivate a subscription."); + IntPtr fresh=EcOpenSubscription(name,1,2);if(fresh==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try{Check(fresh);}finally{EcClose(fresh);} + Variant value=new Variant{Boolean=enabled?1:0,Count=0,Type=1}; + if(!EcSetSubscriptionProperty(handle,0,0,ref value))throw new Win32Exception(Marshal.GetLastWin32Error()); + SaveAttempted=true; + if(!EcSaveSubscription(handle,0))throw new Win32Exception(Marshal.GetLastWin32Error()); + } + public void Dispose(){if(handle!=IntPtr.Zero){EcClose(handle);handle=IntPtr.Zero;}} + } +} diff --git a/tests/AuditPrecedence.Tests.ps1 b/tests/AuditPrecedence.Tests.ps1 index 5b1d7190..cc0db218 100644 --- a/tests/AuditPrecedence.Tests.ps1 +++ b/tests/AuditPrecedence.Tests.ps1 @@ -1,5 +1,6 @@ # Mocked registry/audit policy; no Windows policy changes. $ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force . (Join-Path $PSScriptRoot '../scripts/Configuration.ps1') $script:assertions = 0 $script:paths = @() diff --git a/tests/IpsecPrerequisites.Tests.ps1 b/tests/IpsecPrerequisites.Tests.ps1 new file mode 100644 index 00000000..9d6db3e0 --- /dev/null +++ b/tests/IpsecPrerequisites.Tests.ps1 @@ -0,0 +1,97 @@ +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$script:ScriptRoot = $repo +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +. (Join-Path $repo 'scripts/Configuration.ps1') +$script:checks=0 +function Assert($Condition,[string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:checks++ } +function Rule([string]$Enabled='True',[string]$Inbound='Require',[string]$Outbound='Request',[string]$Health='OK') { + [pscustomobject]@{Name='owned';Enabled=$Enabled;InboundSecurity=$Inbound;OutboundSecurity=$Outbound;PrimaryStatus=$Health} +} +$script:rule=Rule +$positive=Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {} +Assert ($positive.Status -eq 'Applicable' -and $positive.Rules[0].Qualifies) 'healthy effective securing rule qualifies' +$none=Get-WelaIpsecPrerequisite -ReadRules {} -ReadAssociations {} +Assert ($none.Status -eq 'NotObservedWithinScope' -and $none.Limitations -match 'legacy IPsec') 'empty complete inventory is scope-limited absence' +foreach ($candidate in @((Rule False),(Rule False Require Request Inactive),(Rule True None None))) { + $script:rule=$candidate + $evidence=Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {} + Assert ($evidence.Status -eq 'NotObservedWithinScope' -and -not $evidence.Rules[0].Qualifies) 'disabled and exemption-only policies do not qualify' +} +foreach ($candidate in @((Rule True Require Request Error),(Rule True Require Request Unknown),(Rule True Require Request Inactive),(Rule Maybe),([pscustomobject]@{Name='missing'}))) { + $script:rule=$candidate + Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {}).Status -eq 'Unknown') 'invalid or unhealthy policy stays unknown' +} +$script:rule=Rule +Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule; throw 'denied midway'} -ReadAssociations {}).Status -eq 'Unknown') 'partial failed enumeration never qualifies' +Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule} -ReadAssociations {throw 'denied'}).Status -eq 'Unknown') 'failed independent SA observation prevents complete positive evidence' +Assert ((Get-WelaIpsecPrerequisite -ReadRules {$script:rule;$script:rule} -ReadAssociations {}).Status -eq 'Unknown') 'duplicate rule identities rejected' +Assert ((Get-WelaIpsecPrerequisite -ReadRules {1..4097} -ReadAssociations {}).Status -eq 'Unknown') 'native inventory cap remains unknown' +$sa=Get-WelaIpsecPrerequisite -ReadRules {} -ReadAssociations {[pscustomobject]@{Name='1';LocalEndpoint='192.0.2.1';RemoteEndpoint='192.0.2.2'}} +Assert ($sa.Status -eq 'Applicable' -and $sa.MainModeAssociations.Count -eq 1) 'valid native SA is independently positive evidence' +Assert ((Get-WelaIpsecPrerequisite -ReadRules {} -ReadAssociations {[pscustomobject]@{Name='1';LocalEndpoint='unknown';RemoteEndpoint='192.0.2.2'}}).Status -eq 'Unknown') 'malformed SA does not qualify' +Assert ((Get-WelaIpsecPrerequisite -Offline -ReadRules {throw 'must not run'} -ReadAssociations {throw 'must not run'}).Status -eq 'Unknown') 'offline never queries this host' +$script:zero=@{}; foreach ($policy in (Import-WelaAuditProfiles).catalog) {$script:zero[$policy.guid]=0} +$profile='microsoft-stronger-reviewed-2026-09';$guid='0CCE9218-69AE-11D9-BED3-505054503030' +function Plan([switch]$Optional,[switch]$Observe) { Get-WelaAuditProfilePlan -Profile $profile -Role MemberServer -Build 26100 -Current $script:zero -IncludeOptional:$Optional -ObserveIpsec:$Observe -ReadIpsec {$script:evidence} } +$script:evidence=$positive +$plan=Plan -Optional +$row=@($plan.policies|Where-Object id -eq 'IPsec Main Mode')[0] +Assert ($row.conditionalPrerequisite.Status -eq 'Unknown' -and $null -eq $row.targetMask) 'offline conditional plan has no applicable target' +$plan=Plan -Observe +Assert (($plan.policies|Where-Object id -eq 'IPsec Main Mode').action -eq 'Optional (not selected)') 'positive evidence never substitutes for explicit selection' +$plan=Plan -Observe -Optional +Assert (($plan.policies|Where-Object id -eq 'IPsec Main Mode').targetMask -eq 3) 'live selected positive plan retains exact SF mask' +$script:evidence=$none;$plan=Plan -Observe -Optional +Assert (($plan.policies|Where-Object id -eq 'IPsec Main Mode').action -like 'Preserve*') 'scope-limited absence explicitly preserves' +function Single-Plan { + $p=Plan -Optional -Observe + $p.policies=@($p.policies|Where-Object id -eq 'IPsec Main Mode') + $p +} +$script:evidence=$positive;$plan=Single-Plan +$script:state=$script:zero.Clone();$script:writes=0;$script:reads=0 +$contextReader={ [pscustomobject]@{Role='MemberServer';Build=26100} } +$writer={param($Guid,$Mask) $script:writes++;$script:state[$Guid]=$Mask} +$reader={$script:state.Clone()} +$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {$positive} -Confirm:$false +Assert ($result.success -and $script:writes -eq 1 -and $result.results[0].prerequisiteObservations.Count -eq 2) 'direct executor observes and rechecks before write' +$script:state[$guid]=0;$script:writes=0 +$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {$none} -Confirm:$false +Assert ($result.success -and $script:writes -eq 0 -and $result.results[0].status -eq 'Skipped') 'unobserved condition never writes' +$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {$script:reads++;if($script:reads -eq 1){$positive}else{$none}} -Confirm:$false +Assert (-not $result.success -and $script:writes -eq 0 -and $result.results[0].prerequisiteObservations.Count -eq 2) 'last-moment condition drift blocks direct executor' +$plan.profile='microsoft-sct-server2025-2602' +$result=Invoke-WelaAuditProfilePlan $plan -ReadContext $contextReader -ReadPolicy $reader -WritePolicy $writer -ReadIpsec {throw 'unrelated query'} -Confirm:$false +Assert ($result.success -and $script:writes -eq 1) 'other profile intent is unaffected' +$plan=Single-Plan;$plan|Add-Member NoteProperty CustomProfileSource ([pscustomobject]@{}) +Assert (-not (Test-WelaIpsecConditionalPolicy $plan $plan.policies[0])) 'custom profile intent is not reclassified by its id' + +# Public configure adapter: real runner and durable journal, injected native boundaries. +function Get-WelaRegistryState {param($Path,$Name) [pscustomobject]@{ValueExists=$true;Type='DWord';Value=1} } +function Get-WelaAuditPrecedenceSource { $null } +function Get-WelaNativeAuditPolicy {param($Guid) $script:state[$Guid] } +function Invoke-WelaNative {param($FilePath,$Arguments) + Assert (Test-Path -LiteralPath (Join-Path $script:backup 'before.jsonl')) 'journal precedes native write' + $script:writes++;$script:state[$guid]=3 +} +function Read-Host {param($Prompt) $script:evidence=$none; 'y' } +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ipsec-'+[guid]::NewGuid().ToString('N')) +try { + $script:evidence=$positive;$plan=Single-Plan;$script:state[$guid]=0;$script:writes=0 + $script:backup=Join-Path $root 'race';$ctx=New-WelaConfigurationContext -BackupPath $script:backup + Set-WelaProfileAuditControls $ctx $plan -ReadIpsec {$script:evidence} + $result=Complete-WelaConfiguration $ctx -Plan $plan + $row=@($result.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0] + Assert ($row.Status -eq 'Failed' -and $script:writes -eq 0 -and $row.PrerequisiteObservations[-1].Status -eq 'NotObservedWithinScope') 'public runner rechecks after prompt/journal and retains negative evidence' + $script:evidence=$positive;$script:backup=Join-Path $root 'positive';$ctx=New-WelaConfigurationContext -Auto -BackupPath $script:backup + Set-WelaProfileAuditControls $ctx $plan -ReadIpsec {$script:evidence} + $result=Complete-WelaConfiguration $ctx -Plan $plan + $row=@($result.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0] + Assert ($row.Status -eq 'Applied' -and $script:writes -eq 1 -and $row.PrerequisiteObservations.Count -eq 5) 'public runner keeps plan/read/prewrite/readback/final native prerequisite observations' + $script:evidence=$none;$script:backup=Join-Path $root 'negative';$ctx=New-WelaConfigurationContext -Auto -BackupPath $script:backup + Set-WelaProfileAuditControls $ctx $plan -ReadIpsec {$script:evidence} + $result=Complete-WelaConfiguration $ctx -Plan $plan + Assert ($result.Skipped -eq 1 -and $script:writes -eq 1) 'negative public prerequisite is visible even when audit mask already matches' +} finally {if(Test-Path $root){Remove-Item $root -Recurse -Force}} +Write-Host "Passed $script:checks IPsec prerequisite assertions. No native mutations." diff --git a/tests/IpsecPrerequisites.Windows.Tests.ps1 b/tests/IpsecPrerequisites.Windows.Tests.ps1 new file mode 100644 index 00000000..43594695 --- /dev/null +++ b/tests/IpsecPrerequisites.Windows.Tests.ps1 @@ -0,0 +1,109 @@ +param([switch]$AllowDisposablePolicyWrite,[switch]$AllowDisposableIpsecRule) +$ErrorActionPreference='Stop' +if ($env:OS -ne 'Windows_NT') {throw 'Native Windows fixture required.'} +if (-not $AllowDisposablePolicyWrite -or -not $AllowDisposableIpsecRule) {throw 'Disposable audit-policy and owned IPsec-rule opt-in are both required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module NetSecurity -ErrorAction Stop +. (Join-Path $repo 'scripts/Configuration.ps1') +$script:checks=0 +function Assert($Condition,[string]$Message) {if(-not $Condition){throw "FAIL: $Message"};$script:checks++} +$identity=[Security.Principal.WindowsIdentity]::GetCurrent() +Assert ([Security.Principal.WindowsPrincipal]::new($identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) 'fixture is elevated' +$root=Join-Path $env:RUNNER_TEMP ('wela-ipsec-'+[guid]::NewGuid().ToString('N')) +$null=New-Item $root -ItemType Directory +$name='wela-ipsec-'+[guid]::NewGuid().ToString('N') +$guid='0CCE9218-69AE-11D9-BED3-505054503030' +$before=Get-WelaEffectiveAuditPolicy +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' +$precedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy +$beforeRules=@(Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop|Select-Object Name,Enabled,InboundSecurity,OutboundSecurity,PrimaryStatus|Sort-Object Name|ConvertTo-Json -Depth 5 -Compress) +$engine=(Get-Process -Id $PID).Path +$created=$false;$cleanup=$false +try { + $baseline=Get-WelaIpsecPrerequisite + $baseline|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'baseline.json') -Encoding UTF8 + Assert ($baseline.Status -ne 'Unknown') "both native sources are readable: $($baseline.Diagnostic)" + # Both endpoints are documentation-only addresses; no packets or negotiations are generated. + $null=New-NetIPsecRule -Name $name -DisplayName $name -PolicyStore PersistentStore -Profile Any -Enabled False -LocalAddress 192.0.2.250 -RemoteAddress 192.0.2.251 -InboundSecurity Request -OutboundSecurity Request -ErrorAction Stop + $created=$true + $evidence=Get-WelaIpsecPrerequisite + $evidence|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'disabled.json') -Encoding UTF8 + Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'disabled-native.xml') + Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'disabled-active-native.xml') + $owned=@($evidence.Rules|Where-Object Name -eq $name) + Assert ((Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop).Enabled -eq 'False') 'owned persistent rule is actually disabled' + Assert ($evidence.Status -ne 'Unknown' -and @($owned|Where-Object Qualifies).Count -eq 0) "disabled rule does not qualify (ActiveStore may omit it): $($evidence.Diagnostic)" + Set-NetIPsecRule -Name $name -PolicyStore PersistentStore -Enabled True -InboundSecurity None -OutboundSecurity None -ErrorAction Stop + $evidence=Get-WelaIpsecPrerequisite + $owned=@($evidence.Rules|Where-Object Name -eq $name) + $evidence|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'exemption.json') -Encoding UTF8 + Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'exemption-native.xml') + Assert ($evidence.Status -ne 'Unknown' -and @($owned|Where-Object Qualifies).Count -eq 0) "real exemption-only rule does not qualify: $($evidence.Diagnostic)" + # Converting to an exemption clears its authentication-set references. Recreate + # only this owned fixture so New-NetIPsecRule supplies valid native defaults. + Remove-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop + $created=$false + $null=New-NetIPsecRule -Name $name -DisplayName $name -PolicyStore PersistentStore -Profile Any -Enabled True -LocalAddress 192.0.2.250 -RemoteAddress 192.0.2.251 -InboundSecurity Request -OutboundSecurity Request -ErrorAction Stop + $created=$true + $evidence=Get-WelaIpsecPrerequisite + $evidence|ConvertTo-Json -Depth 10|Set-Content (Join-Path $root 'positive.json') -Encoding UTF8 + Get-NetIPsecRule -Name $name -PolicyStore ActiveStore -ErrorAction Stop|Select-Object *|Export-Clixml (Join-Path $root 'positive-native.xml') + $owned=@($evidence.Rules|Where-Object Name -eq $name) + Assert ($evidence.Status -eq 'Applicable' -and $owned.Count -eq 1 -and $owned[0].Qualifies) "real enabled securing ActiveStore rule establishes scoped applicability: $($evidence.Diagnostic)" + $planPath=Join-Path $root 'plan.json' + & $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') plan -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -SaclMode Skip -PlanPath $planPath + Assert ($LASTEXITCODE -eq 0) 'public live plan succeeds' + $plan=Get-Content $planPath -Raw|ConvertFrom-Json + $row=@($plan.policies|Where-Object id -eq 'IPsec Main Mode')[0] + Assert ($row.conditionalPrerequisite.Status -eq 'Applicable' -and $row.targetMask -eq 3) 'public plan contains native evidence and selected SF mask' + $dryPath=Join-Path $root 'dry.json' + & $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -SaclMode Skip -DryRun -Auto -ResultsPath $dryPath + Assert ($LASTEXITCODE -eq 0) 'public configure dry-run succeeds' + $current=Get-WelaEffectiveAuditPolicy + Assert (@($before.Keys|Where-Object {$before[$_] -ne $current[$_]}).Count -eq 0) 'dry-run preserves all59 effective masks' + $dry=Get-Content $dryPath -Raw|ConvertFrom-Json + $row=@($dry.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0] + Assert ($row.PrerequisiteObservations.Count -ge 2 -and $row.Status -in @('Skipped','AlreadyCompliant')) 'public dry-run retains native prerequisite evidence' + + # Actual public configure must produce a write for this control, then read it back. + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact + $resultPath=Join-Path $root 'configure.json' + & $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -Profile microsoft-stronger-reviewed-2026-09 -IncludeOptional -SaclMode Skip -Auto -BackupPath (Join-Path $root 'backup') -ResultsPath $resultPath + Assert ($LASTEXITCODE -eq 0) 'actual public configure succeeds' + $result=Get-Content $resultPath -Raw|ConvertFrom-Json + $row=@($result.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0] + Assert ($row.Status -eq 'Applied' -and $row.After -eq 3 -and $row.PrerequisiteObservations.Count -eq 5) 'actual gated policy write retains all five native observations' + Assert (@($row.PrerequisiteObservations|Where-Object Status -ne Applicable).Count -eq 0) 'every configure boundary has positive native evidence' + $journal=@(Get-Content (Join-Path $root 'backup/before.jsonl')|ConvertFrom-Json) + Assert (@($journal|Where-Object {$_.Id -eq 'AuditPolicy/IPsec Main Mode' -and $_.Before -eq 0 -and $_.Desired.Mask -eq 3}).Count -eq 1) 'real public recovery journal retains exact policy transition' + + # Native drift after prompt: exercise the real configuration callback and native reader. + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact + $plan.policies=@($plan.policies|Where-Object id -eq 'IPsec Main Mode') + function Read-Host {param($Prompt) Remove-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop; $script:created=$false; 'y'} + $ctx=New-WelaConfigurationContext -BackupPath (Join-Path $root 'drift-backup') + Set-WelaProfileAuditControls $ctx $plan + $drift=Complete-WelaConfiguration $ctx -Plan $plan -ResultsPath (Join-Path $root 'drift.json') + $row=@($drift.Results|Where-Object Id -eq 'AuditPolicy/IPsec Main Mode')[0] + if($baseline.Status -eq 'NotObservedWithinScope') { + Assert ($row.Status -eq 'Failed' -and (Get-WelaEffectiveAuditPolicy)[$guid] -eq 0) 'real rule disappearance after prompt blocks auditpol write' + } else { + Assert ($row.Status -eq 'Applied') 'independent baseline prerequisite remains applicable after owned-rule removal' + } +} finally { + if(@(Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction SilentlyContinue).Count){Remove-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction Stop} + foreach($id in $before.Keys){Set-WelaEffectiveAuditPolicy -Guid $id -Mask $before[$id] -Mode exact} + if($precedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $precedence.Type -Value $precedence.Value -ErrorAction Stop} + else {Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue} + $after=Get-WelaEffectiveAuditPolicy + Assert (@($before.Keys|Where-Object {$before[$_] -ne $after[$_]}).Count -eq 0) 'all59 original masks restored' + $afterPrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy + Assert (($precedence|ConvertTo-Json -Compress) -ceq ($afterPrecedence|ConvertTo-Json -Compress)) 'typed precedence/absence restored' + $afterRules=@(Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop|Select-Object Name,Enabled,InboundSecurity,OutboundSecurity,PrimaryStatus|Sort-Object Name|ConvertTo-Json -Depth 5 -Compress) + Assert (($beforeRules -join '') -ceq ($afterRules -join '')) 'native rule inventory restored exactly' + Assert (@(Get-NetIPsecRule -Name $name -PolicyStore PersistentStore -ErrorAction SilentlyContinue).Count -eq 0) 'owned persistent rule removed' + $cleanup=$true + [pscustomobject]@{CleanupVerified=$cleanup;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();Computer=$env:COMPUTERNAME;NoTrafficGenerated=$true}|ConvertTo-Json|Set-Content (Join-Path $root 'cleanup.json') -Encoding UTF8 +} +Write-Host "Passed $script:checks native IPsec checks; artifacts: $root" diff --git a/tests/SmbRuntimeActivation.Cli.Tests.ps1 b/tests/SmbRuntimeActivation.Cli.Tests.ps1 new file mode 100644 index 00000000..d06189d0 --- /dev/null +++ b/tests/SmbRuntimeActivation.Cli.Tests.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path +$script:checks=0 +function Check-Cli { + param([string[]]$Arguments,[bool]$Success,[string]$Match) + $old=$ErrorActionPreference;$ErrorActionPreference='Continue' + try{$output=(& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @Arguments 2>&1 | Out-String);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + if(($Success -and $code -ne 0) -or (-not $Success -and $code -eq 0) -or $output -notmatch $Match){throw "CLI guard failed: $($Arguments -join ' '), exit $code : $output"} + $script:checks++ +} +Check-Cli @('smb-runtime','-Help') $true 'smb-runtime' +Check-Cli @('smb-runtime','-Profile','test','-Help') $false 'dedicated options' +Check-Cli @('help','-SmbRuntimeAction','Activate') $false 'SmbRuntime options require' +Check-Cli @('smb-runtime','-SmbAction','Configure','-Help') $false 'dedicated options' +Check-Cli @('smb-runtime','-DryRun') $false 'DryRun is supported only' +Check-Cli @('smb-runtime','-SmbRuntimeAction','Activate','-DryRun','-Help') $true 'smb-runtime' +Check-Cli @('smb-runtime','-BackupPath','unused','-Help') $false 'dedicated options' +Write-Host "PASS: $script:checks public SMB runtime CLI guards" +# Expected child failures are assertions, not the enclosing Actions step result. +$global:LASTEXITCODE=0 diff --git a/tests/SmbRuntimeActivation.Tests.ps1 b/tests/SmbRuntimeActivation.Tests.ps1 new file mode 100644 index 00000000..c9812f77 --- /dev/null +++ b/tests/SmbRuntimeActivation.Tests.ps1 @@ -0,0 +1,130 @@ +$ErrorActionPreference='Stop' +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1') +. (Join-Path $script:ScriptRoot 'scripts/SmbAuditing.ps1') +. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1') +. (Join-Path $script:ScriptRoot 'scripts/SmbRuntimeActivation.ps1') +$script:checks=0 +function Assert($Condition,[string]$Message){if(-not $Condition){throw "FAIL: $Message"};$script:checks++} +function Reject([scriptblock]$Code,[string]$Message){$failed=$false;try{& $Code}catch{$failed=$true};Assert $failed $Message} +Reject {Set-WelaSmbRuntimeFlag 'LanmanWorkstation/EnableInsecureGuestLogons'} 'security parameter refused by actual setter adapter' +Reject {Set-WelaSmbRuntimeFlag 'LanmanServer/auditinsecureguestlogon'} 'mis-cased control refused' +$nativeModuleBase=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$command=[pscustomobject]@{Name='Set-SmbServerConfiguration';ModuleName='SmbServerConfiguration';CommandType='Function';Module=[pscustomobject]@{ModuleBase=$nativeModuleBase};Parameters=@{}} +foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq LanmanServer)){$command.Parameters[$definition.Name]=[pscustomobject]@{ParameterType=[bool]}} +Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase +Assert $true 'actual nested native CDXML module metadata accepted' +$command.ModuleName='Other' +Reject {Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase} 'foreign module refused' +$command.ModuleName='SmbServerConfiguration' +Reject {Assert-WelaSmbRuntimeCommand $command Server Set ($nativeModuleBase+'other')} 'unexpected module directory refused' +$command.Parameters.AuditInsecureGuestLogon.ParameterType=[string] +Reject {Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase} 'mistyped native parameter refused' +$command.Parameters.Remove('AuditInsecureGuestLogon') +Reject {Assert-WelaSmbRuntimeCommand $command Server Set $nativeModuleBase} 'missing native parameter refused' +function FixtureConfiguration { + param([string]$Side='Server') + $component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'} + $properties=@(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component | ForEach-Object {[pscustomobject]@{Name=$_.Name;Value=$false;CimType='Boolean'}}) + $properties+=[pscustomobject]@{Name='RequireSecuritySignature';Value=$true;CimType='Boolean'} + [pscustomobject]@{CimClass=[pscustomobject]@{CimClassName="MSFT_Smb${Side}Configuration"};CimInstanceProperties=$properties} +} +$native=FixtureConfiguration +$config=ConvertTo-WelaSmbRuntimeConfiguration $native Server +Assert ($config.RequireSecuritySignature.Value -eq $true -and $config.AuditInsecureGuestLogon.Value -eq $false) 'native typed security and audit properties retained' +$native.CimInstanceProperties[0].Value='False' +Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'string audit Boolean rejected' +$native=FixtureConfiguration;$native.CimInstanceProperties[0].CimType='String' +Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'wrong native CIM type rejected' +$native=FixtureConfiguration;$native.CimClass.CimClassName='MSFT_AnotherConfiguration' +Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'wrong native class rejected' +$native=FixtureConfiguration;$native.CimInstanceProperties+=[pscustomobject]@{Name='Mystery';Value=[pscustomobject]@{a=1};CimType='Instance'} +Reject {ConvertTo-WelaSmbRuntimeConfiguration $native Server} 'unknown unrelated configuration remains unverified' + +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-smb-activation-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $root +$script:receiptWriter=${function:Write-WelaSmbRuntimeReceipt} +function Reset-Fixture { + $policies=[ordered]@{} + foreach($definition in Get-WelaSmbAuditDefinitions){$policies["$($definition.Component)/$($definition.Name)"]=[pscustomobject]@{Policy=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Type=$null;Value=$null}}} + $script:fixture=[pscustomobject][ordered]@{Computer='fixture';Host=[pscustomobject]@{Build=26100};Commands='native';Sources='hash';Policies=[pscustomobject]$policies;Configurations=[pscustomobject]@{Server=(ConvertTo-WelaSmbRuntimeConfiguration (FixtureConfiguration Server) Server);Client=(ConvertTo-WelaSmbRuntimeConfiguration (FixtureConfiguration Client) Client)}} + $script:writes=0;$script:reads=0;$script:driftRead=0;$script:failWrite=0;$script:securityDrift=$false;$script:receiptFail=$false;$script:promptDrift=$false + $script:out=Join-Path $root ([guid]::NewGuid().ToString('N')) +} +function Get-WelaSmbRuntimeState { + $script:reads++ + if($script:reads -eq $script:driftRead){$script:fixture.Sources='changed'} + Get-WelaSmbRuntimeKey $script:fixture | ConvertFrom-Json +} +function Write-WelaSmbRuntimeReceipt { + param($Root,$Name,$Value) + if($script:receiptFail -and $Name -eq '1-pending.json'){throw 'Injected durable-write failure'} + & $script:receiptWriter $Root $Name $Value +} +function Set-WelaSmbRuntimeFlag { + param($Id) + $script:writes++ + Assert (Test-Path (Join-Path $script:out "$($script:writes)-pending.json")) 'pending receipt exists before setter' + if($script:writes -eq $script:failWrite){throw 'Injected native setter failure'} + $parts=$Id.Split('/');$side=if($parts[0] -eq 'LanmanServer'){'Server'}else{'Client'} + $script:fixture.Configurations.$side.($parts[1]).Value=$true + if($script:securityDrift){$script:fixture.Configurations.Server.RequireSecuritySignature.Value=$false} +} +function Read-Host {param($Prompt) if($script:promptDrift){$script:fixture.Sources='changed at prompt'};'y'} +try { + Reset-Fixture + $plan=Invoke-WelaSmbRuntimeActivation + Assert ($plan.Status -eq 'Planned' -and $plan.Controls.Count -eq 6 -and $script:writes -eq 0) 'default Plan is six read-only audit controls' + Assert (-not (Test-Path $script:out)) 'Plan creates no evidence directory' + $dry=Invoke-WelaSmbRuntimeActivation -Action Activate -DryRun -OutputPath $script:out + Assert ($dry.Status -eq 'DryRun' -and $script:writes -eq 0 -and -not (Test-Path $script:out)) 'DryRun does not write' + Reject {Invoke-WelaSmbRuntimeActivation -Action Plan -Auto} 'irrelevant Plan consent rejected' + Reject {Invoke-WelaSmbRuntimeActivation -Action Plan -DryRun} 'invalid dry run action rejected' + $id='LanmanServer/AuditInsecureGuestLogon' + foreach($value in @(0,'1',2)) { + Reset-Fixture;$script:fixture.Policies.$id.Policy=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Type='DWord';Value=$value} + $report=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and -not (Test-Path $script:out)) 'conflicting or mistyped policy stops all mutations' + } + Reset-Fixture;$script:fixture.Policies.$id.Policy=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Type='String';Value=1} + Assert ((Invoke-WelaSmbRuntimeActivation).ExitCode -eq 1) 'wrong registry kind blocks' + Reset-Fixture;$script:fixture.Policies.$id.Policy=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Type='DWord';Value=1} + Assert ((Invoke-WelaSmbRuntimeActivation).ExitCode -eq 0) 'existing enabled policy is compatible' + + Reset-Fixture + $report=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($report.ExitCode -eq 0 -and $report.Status -eq 'RuntimeAuditingActive' -and $script:writes -eq 6) "six native activations succeed: $($report.Diagnostic)" + Assert (@($report.Results | Where-Object Status -eq Activated).Count -eq 6) 'all six report confirmed activation' + Assert ((Get-ChildItem -LiteralPath $script:out -File).Count -eq 14) 'plan, six pending, six confirmed, final result retained' + Assert ($report.After.Configurations.Server.RequireSecuritySignature.Value -eq $true) 'security property preserved' + Assert ($report.ReadyRuleCredit -eq 0 -and $report.EventGeneration -eq 'Not tested') 'activation grants no event or rule proof' + $prior=Get-Content -Raw -LiteralPath (Join-Path $script:out 'result.json') + $second=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($second.ExitCode -eq 1 -and (Get-Content -Raw -LiteralPath (Join-Path $script:out 'result.json')) -ceq $prior) 'existing evidence is never overwritten' + $script:out=Join-Path $root ([guid]::NewGuid().ToString('N'));$script:writes=0 + $repeat=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($repeat.ExitCode -eq 0 -and $script:writes -eq 0 -and @($repeat.Results | Where-Object Status -eq AlreadyActive).Count -eq 6) 'idempotence requires no setters' + + Reset-Fixture;$script:failWrite=2 + $partial=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($partial.ExitCode -eq 1 -and $script:writes -eq 2) 'partial native failure stops remaining writes' + Assert ($partial.Results[0].Status -eq 'Activated' -and $partial.Results[1].Status -eq 'Failed' -and $partial.Results[2].Status -eq 'Skipped') 'partial outcomes preserved' + Assert ((Test-Path (Join-Path $script:out '1-confirmed.json')) -and -not (Test-Path (Join-Path $script:out '2-confirmed.json'))) 'failed operation is never confirmed' + foreach($read in @(2,3,20)) { + Reset-Fixture;$script:driftRead=$read + $drift=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($drift.ExitCode -eq 1) 'fresh/prewrite/final source drift fails closed' + if($read -lt 4){Assert ($script:writes -eq 0) 'prewrite drift performs no setter'} + } + Reset-Fixture;$script:promptDrift=$true + $drift=Invoke-WelaSmbRuntimeActivation -Action Activate -OutputPath $script:out + Assert ($drift.ExitCode -eq 1 -and $script:writes -eq 0) 'prompt-time drift refused' + Reset-Fixture;$script:securityDrift=$true + $drift=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($drift.ExitCode -eq 1 -and $script:writes -eq 1 -and -not (Test-Path (Join-Path $script:out '1-confirmed.json'))) 'unrelated security delta prevents confirmation' + Reset-Fixture;$script:receiptFail=$true + $failed=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $script:out + Assert ($failed.ExitCode -eq 1 -and $script:writes -eq 0) 'failed durable intent blocks setter' + Assert (Test-Path (Join-Path $script:out 'result.json')) 'partial diagnostic survives pending-write failure' + Write-Host "PASS: $script:checks SMB runtime activation assertions" +}finally{Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue} diff --git a/tests/SmbRuntimeActivation.Windows.Tests.ps1 b/tests/SmbRuntimeActivation.Windows.Tests.ps1 new file mode 100644 index 00000000..ebec31d7 --- /dev/null +++ b/tests/SmbRuntimeActivation.Windows.Tests.ps1 @@ -0,0 +1,85 @@ +# Mutates only six audit flags on disposable GitHub-hosted Windows VMs. Never run on production. +$ErrorActionPreference='Stop' +if($env:OS -ne 'Windows_NT' -or $env:GITHUB_ACTIONS -ne 'true' -or $env:WELA_DISPOSABLE_SMB_ACTIVATION -ne 'true') {throw 'Explicit disposable GitHub Windows test opt-in is required.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1') +. (Join-Path $script:ScriptRoot 'scripts/SmbAuditing.ps1') +. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1') +. (Join-Path $script:ScriptRoot 'scripts/SmbRuntimeActivation.ps1') +$computer=Get-CimInstance Win32_ComputerSystem +$os=Get-CimInstance Win32_OperatingSystem +if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)){throw 'Fixture requires an isolated member-class Server 2022/2025 host.'} +$evidence=Join-Path $env:RUNNER_TEMP ('wela-smb-runtime-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $evidence +$reportPath=Join-Path $evidence 'activation' +$cleanup=[ordered]@{Build=[int]$os.BuildNumber;Engine=$PSVersionTable.PSVersion.ToString();OriginalCaptured=$false;AuditFlagsRestored=$false;FullContextRestored=$false;NativeActivation=$false;UnsupportedRefusal=$false} +$original=$null +try { + if([int]$os.BuildNumber -eq 20348) { + $report=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath $reportPath + if($report.ExitCode -ne 1 -or $report.Diagnostic -notlike '*NotApplicable*' -or (Test-Path $reportPath)){throw 'Server 2022 activation was not refused before writes.'} + $report | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath (Join-Path $evidence 'refusal.json') -Encoding UTF8 + $global:LASTEXITCODE=0 + $null=& (Join-Path $script:ScriptRoot 'WELA.ps1') smb-runtime -SmbRuntimeAction Activate -SmbRuntimeOutputPath $reportPath -Auto + if($LASTEXITCODE -ne 1 -or (Test-Path $reportPath)){throw 'Public CLI did not refuse unsupported Server 2022.'} + $cleanup.UnsupportedRefusal=$true + Write-Host 'PASS: actual Server 2022 native and public-CLI refusal, no output or setters.' + }else{ + $original=Get-WelaSmbRuntimeState + if(@(Get-WelaSmbRuntimePlan $original | Where-Object Status -eq BlockedPolicy).Count){throw 'Fixture will not overwrite a conflicting policy.'} + $cleanup.OriginalCaptured=$true + $original | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath (Join-Path $evidence 'original.json') -Encoding UTF8 + foreach($definition in Get-WelaSmbAuditDefinitions) { + $side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'} + $command="SmbShare\Set-Smb${side}Configuration" + $parameters=@{Force=$true;Confirm=$false;ErrorAction='Stop'};$parameters[$definition.Name]=$false + $null=& $command @parameters + } + $prepared=Get-WelaSmbRuntimeState + $expected=Get-WelaSmbRuntimeKey $original | ConvertFrom-Json + foreach($definition in Get-WelaSmbAuditDefinitions) { + $side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'} + $expected.Configurations.$side.($definition.Name).Value=$false + } + if((Get-WelaSmbRuntimeKey $prepared) -cne (Get-WelaSmbRuntimeKey $expected)){throw 'Fixture preparation changed other settings or did not make audit flags False.'} + $dry=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -DryRun -OutputPath $reportPath + if($dry.ExitCode -ne 0 -or (Test-Path $reportPath) -or (Get-WelaSmbRuntimeKey (Get-WelaSmbRuntimeState)) -cne (Get-WelaSmbRuntimeKey $prepared)){throw 'Native dry-run changed context or wrote output.'} + $global:LASTEXITCODE=0 + $cli=@(& (Join-Path $script:ScriptRoot 'WELA.ps1') smb-runtime -SmbRuntimeAction Activate -SmbRuntimeOutputPath $reportPath -Auto) + if($LASTEXITCODE -ne 0){throw "Public CLI exited $LASTEXITCODE"} + $report=Get-Content -Raw -LiteralPath (Join-Path $reportPath 'result.json') | ConvertFrom-Json + if($report.ExitCode -ne 0 -or $report.Status -ne 'RuntimeAuditingActive' -or @($report.Results | Where-Object Status -eq Activated).Count -ne 6){throw "Native six-flag activation failed: $($report.Diagnostic)"} + $active=Get-WelaSmbRuntimeState + foreach($definition in Get-WelaSmbAuditDefinitions) { + $side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'} + $expected.Configurations.$side.($definition.Name).Value=$true + } + if((Get-WelaSmbRuntimeKey $active) -cne (Get-WelaSmbRuntimeKey $expected)){throw 'Activation did not preserve every unrelated configuration field and policy tuple.'} + $repeat=Invoke-WelaSmbRuntimeActivation -Action Activate -Auto -OutputPath (Join-Path $evidence 'idempotent') + if($repeat.ExitCode -ne 0 -or @($repeat.Results | Where-Object Status -eq AlreadyActive).Count -ne 6){throw 'Native idempotence failed.'} + if(@(Get-ChildItem -LiteralPath $repeat.OutputPath -Filter '*-pending.json').Count){throw 'Idempotent run unexpectedly journaled a setter.'} + $cleanup.NativeActivation=$true + Write-Host 'PASS: actual Server 2025 public-CLI activation of all six native Boolean audit flags, dry-run, idempotence and preservation of all unrelated native configuration.' + } +}finally{ + if($original) { + $failures=@() + foreach($definition in Get-WelaSmbAuditDefinitions) { + try { + $side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'} + $command="SmbShare\Set-Smb${side}Configuration" + $parameters=@{Force=$true;Confirm=$false;ErrorAction='Stop'};$parameters[$definition.Name]=[bool]$original.Configurations.$side.($definition.Name).Value + $null=& $command @parameters + }catch{$failures+=$_.Exception.Message} + } + $restored=Get-WelaSmbRuntimeState + $restored | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath (Join-Path $evidence 'restored.json') -Encoding UTF8 + $cleanup.AuditFlagsRestored=$failures.Count -eq 0 + $cleanup.FullContextRestored=(Get-WelaSmbRuntimeKey $restored) -ceq (Get-WelaSmbRuntimeKey $original) + $cleanup | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $evidence 'acceptance.json') -Encoding UTF8 + if(-not $cleanup.AuditFlagsRestored -or -not $cleanup.FullContextRestored){throw "Native SMB fixture cleanup mismatch: $($failures -join '; ')"} + Write-Host 'PASS: exact native audit flags and full configuration/policy/source context restored.' + }else{$cleanup | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $evidence 'acceptance.json') -Encoding UTF8} + Write-Host "Native SMB evidence: $evidence" +} +$global:LASTEXITCODE=0 diff --git a/tests/WecState.Cli.Tests.ps1 b/tests/WecState.Cli.Tests.ps1 new file mode 100644 index 00000000..c4b42df4 --- /dev/null +++ b/tests/WecState.Cli.Tests.ps1 @@ -0,0 +1,17 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('wec-state','-Help');Code=0;Pattern='Disable interrupts'}, + @{Args=@('configure','-WecStateAction','Apply','-Auto');Code=1;Pattern='require wec-state'}, + @{Args=@('wec-state','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-state','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-state','-Help','-WecUpdateAction','Apply');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-state','-Help','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-state','-Help','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-state','-Help','-ResultsPath','not-created');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-state','-WecStateAction','Apply','-WecStateOutputPath','not-created');Code=1;Pattern='reviewed plan'}, + @{Args=@('wec-state','-WecStateOutputPath','not-created');Code=1;Pattern='Plan requires'} +) +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(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++} +Write-Host "WEC state CLI: $count checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/WecState.Tests.ps1 b/tests/WecState.Tests.ps1 new file mode 100644 index 00000000..3840f8ce --- /dev/null +++ b/tests/WecState.Tests.ps1 @@ -0,0 +1,114 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/WecState.ps1" +Initialize-WelaWecStateNative +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-wec-state-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$sid='S-1-5-21-11-22-33-1001';$id='WELA Native Security Example' +$base=[IO.File]::ReadAllText("$repo/config/wef-examples/native-security.xml").Replace('true','false').Replace('',(''+(Get-WelaWefAuthorization @($sid))+'')) +$script:xml=$base;$script:saves=0;$script:reads=0;$script:contextReads=0;$script:mode='ok';$script:journal='' +function Get-WelaWecStateContext { + $script:contextReads++;$token='11'*56 + if($script:mode -eq 'token-drift' -and $script:contextReads -gt 1){$token='22'*56} + [pscustomobject][ordered]@{Computer='TEST';HostKey='20348';Reader=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000';TokenStatistics=$token};Service='Running';DestinationLog=[pscustomobject]@{Enabled=($script:mode -ne 'disabled-destination')}} +} +function Read-WelaWecStateDefinition { + param($Id,$SourceSids) + $script:reads++ + if($script:mode -eq 'drift' -and $script:reads -eq 2){$script:xml=$script:xml.Replace('MinLatency','Normal')} + if($script:mode -eq 'denied'){throw 'Native access denied'} + Get-WelaWecStateDefinition $script:xml $SourceSids +} +function Read-WelaWecStateRuntime {param($Id);[pscustomobject]@{Status='Unknown';Diagnostic='Runtime unavailable';ReadyRuleCredit=0}} +function New-WelaWecStateEdit { + param($Before) + $edit=[pscustomobject]@{SaveAttempted=$false} + $edit|Add-Member ScriptMethod Save {param($Enabled) + Assert (Test-Path -LiteralPath $script:journal) 'Durable pending record precedes native save' + $pending=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($script:journal)) + Assert ($pending.Status -ceq 'Pending' -and $pending.DesiredEnabled -eq $Enabled) 'Pending receipt names exact desired state' + if($script:mode -eq 'native-refusal'){throw 'Native view changed before save'} + $this.SaveAttempted=$true;$script:saves++ + if($script:mode -eq 'failure'){throw 'native save failed'} + if($script:mode -eq 'false-success'){return} + $doc=Read-WelaWefXml $script:xml;$doc.Subscription.Enabled=$Enabled.ToString().ToLowerInvariant() + if($script:mode -eq 'preservation'){$doc.Subscription.ReadExistingEvents='true'} + $script:xml=$doc.OuterXml + if($script:mode -eq 'evidence-tamper'){[IO.File]::AppendAllText($script:journal,' ')} + } + $edit|Add-Member ScriptMethod Dispose {} + $edit +} +try { + $before=Get-WelaWecStateDefinition $base @($sid) + Assert (-not $before.Enabled -and $before.Id -ceq $id) 'Disabled original parsed' + $enabled=Get-WelaWecStateDefinition ($base.Replace('false','true')) @($sid) + Assert ($enabled.Enabled -and $enabled.PreservedKey -ceq $before.PreservedKey -and $enabled.WholeKey -cne $before.WholeKey) 'Only Enabled excluded from preservation comparison' + Reject {Get-WelaWecStateDefinition $base @('S-1-1-0')} 'SID' + Reject {Get-WelaWecStateDefinition ($base.Replace('Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"')) @($sid)} 'Sysmon' + Reject {Get-WelaWecStateDefinition ($base.Replace('SourceInitiated','CollectorInitiated')) @($sid)} 'source-initiated' + Reject {Get-WelaWecStateDefinition ($base.Replace('false','falsetrue')) @($sid)} 'duplicate' + Reject {Invoke-WelaWecState -Id $id -SourceSids @($sid) -OutputPath (Join-Path $root 'invalid')} 'Plan requires' + Reject {Invoke-WelaWecState -Action Apply -PlanPath missing -PlanHash ('a'*64) -State Enabled -OutputPath (Join-Path $root 'invalid')} 'only' + foreach($scenario in @('ok','drift','token-drift','failure','false-success','preservation','hash','stale','context','duplicate-key','wrong-type','source-hash','denied','native-refusal','evidence-tamper')){ + $script:xml=$base;$script:mode='ok';$script:reads=0;$script:contextReads=0;$script:saves=0 + $planResult=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Enabled -OutputPath (Join-Path $root ($scenario+'-plan')) + Assert ($planResult.ExitCode -eq 0 -and $planResult.Status -eq 'ReviewRequired') "Plan created: $($planResult.Diagnostic)" + Assert ($script:saves -eq 0 -and -not $planResult.BeforeEnabled -and $planResult.DesiredEnabled) 'Plan is read only and states exact transition' + $planPath=Join-Path $planResult.OutputPath 'plan.json';$hash=$planResult.PlanHash + $script:mode=$scenario;$script:reads=0;$script:contextReads=0 + if($scenario -eq 'hash'){$hash='b'*64} + if($scenario -eq 'stale'){$script:xml=$base.Replace('MinLatency','Normal')} + if($scenario -in @('context','duplicate-key','wrong-type','source-hash')){ + $text=[IO.File]::ReadAllText($planPath) + switch($scenario){ + context {$text=$text.Replace('TEST','OTHER')} + duplicate-key {$text=$text.Replace('"SchemaVersion":','"SchemaVersion": 1, "SchemaVersion":')} + wrong-type {$text=$text -replace '"DesiredEnabled":\s*true','"DesiredEnabled": "true"'} + source-hash {$text=$text.Replace('scripts/WecState.ps1','scripts/Untrusted.ps1')} + } + [IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant() + } + $out=Join-Path $root ($scenario+'-apply');$script:journal=Join-Path $out 'before-save.json' + $result=Invoke-WelaWecState Apply -PlanPath $planPath -PlanHash $hash -OutputPath $out + Assert (($result.ExitCode -eq 0) -eq ($scenario -eq 'ok')) "Scenario $scenario : $($result.Diagnostic)" + Assert ($result.ReadyRuleCredit -eq 0 -and $result.BookmarkContinuity -eq 'Not established') 'No delivery/bookmark/Sigma credit' + Assert (Test-Path (Join-Path $out 'manifest.json')) 'Result retained' + if($scenario -in @('drift','token-drift','hash','stale','context','duplicate-key','wrong-type','source-hash','denied','native-refusal')){Assert ($script:saves -eq 0 -and -not $result.NativeSaveAttempted) 'Rejected before native save'} + if($scenario -in @('failure','false-success','preservation','evidence-tamper')){Assert ($result.Status -eq 'SaveAttemptedUnverified' -and $result.NativeSaveAttempted) 'Partial failure remains explicit'} + if($scenario -eq 'ok'){ + $after=Get-WelaWecStateDefinition $script:xml @($sid) + Assert ($after.PreservedKey -ceq $before.PreservedKey -and $after.Enabled -and $result.Status -eq 'StateChangedAndVerified') 'Only Enabled changed' + Assert ($result.RuntimeAfter.Status -eq 'Unknown') 'Unknown runtime does not become healthy or invalidate observed configuration' + } + } + $script:mode='disabled-destination';$script:xml=$base;$script:saves=0 + $blocked=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Enabled -OutputPath (Join-Path $root 'disabled-destination-plan') + Assert ($blocked.Status -eq 'Refused' -and $blocked.Diagnostic -match 'ForwardedEvents' -and $script:saves -eq 0) 'Disabled destination is rejected before planning activation' + $disabled=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Disabled -OutputPath (Join-Path $root 'disabled-destination-disable') + Assert ($disabled.ExitCode -eq 0) 'Disabled destination does not block a reviewed disable plan' + foreach($desired in @('Enabled','Disabled')){ + $script:mode='ok';$script:xml=if($desired -eq 'Disabled'){$base}else{$base.Replace('false','true')};$script:saves=0 + $planResult=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State $desired -OutputPath (Join-Path $root ($desired+'-same-plan')) + $result=Invoke-WelaWecState Apply -PlanPath (Join-Path $planResult.OutputPath 'plan.json') -PlanHash $planResult.PlanHash -OutputPath (Join-Path $root ($desired+'-same-apply')) + Assert ($result.Status -eq 'AlreadyMatches' -and $result.ExitCode -eq 0 -and $script:saves -eq 0) 'Idempotent enabled/disabled state never saves/reactivates' + } + $script:xml=$base.Replace('false','true');$script:saves=0 + $planResult=Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Disabled -OutputPath (Join-Path $root 'disable-plan') + $out=Join-Path $root 'disable-apply';$script:journal=Join-Path $out 'before-save.json' + $result=Invoke-WelaWecState Apply -PlanPath (Join-Path $planResult.OutputPath 'plan.json') -PlanHash $planResult.PlanHash -OutputPath $out + Assert ($result.ExitCode -eq 0 -and $result.BeforeEnabled -and -not $result.DesiredEnabled -and (Get-WelaWecStateDefinition $script:xml @($sid)).WholeKey -ceq $before.WholeKey) 'Explicit disable restores exact original XML semantics' + Reject {Invoke-WelaWecState -Id $id -SourceSids @($sid) -State Disabled -OutputPath $root} 'new directory' + $one=Get-WelaWecStateContext;$two=Get-WelaWecStateContext;$two.Reader.TokenStatistics=('22'*8)+$two.Reader.TokenStatistics.Substring(16) + Assert ((Get-WelaWecStateReviewKey $one) -ceq (Get-WelaWecStateReviewKey $two)) 'Different token objects in the same logon can use a reviewed plan' + $two.Reader.TokenStatistics='22'*56 + Assert ((Get-WelaWecStateReviewKey $one) -cne (Get-WelaWecStateReviewKey $two)) 'Different actual logon cannot reuse a reviewed plan' +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "WEC state tests passed: $count assertions." diff --git a/tests/WecState.Windows.Tests.ps1 b/tests/WecState.Windows.Tests.ps1 new file mode 100644 index 00000000..ea38ffa3 --- /dev/null +++ b/tests/WecState.Windows.Tests.ps1 @@ -0,0 +1,112 @@ +param([switch]$AllowDisposableSubscription) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/ControlApplicability.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/WecRuntime.ps1" +. "$repo/scripts/WecState.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 16 -Compress} +function ServiceState {Get-CimInstance Win32_Service -Filter "Name='Wecsvc'"|Select-Object Name,State,StartMode} +function ChannelState { + $c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents') + try {[pscustomobject]@{Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()} +} +function Set-ChannelEnabled([bool]$Enabled){$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{$c.IsEnabled=$Enabled;$c.SaveChanges()}finally{$c.Dispose()}} +function Subscriptions {@((Invoke-WelaNative 'wecutil.exe' @('es')).Output|ForEach-Object {$_.ToString().Trim()}|Where-Object {$_})} +function Invoke-Cli { + param([string[]]$Arguments,[string]$Output,[bool]$Success=$true) + $engine=(Get-Process -Id $PID).Path + $prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" wec-state @Arguments -WecStateOutputPath $Output 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + Assert (($code -eq 0) -eq $Success) "Public CLI exit $code : $($text -join ' ')" + $manifest=Join-Path $Output 'manifest.json';Assert (Test-Path $manifest) 'Public command emitted actual durable result' + Get-Content -LiteralPath $manifest -Raw|ConvertFrom-Json +} +$beforeService=ServiceState;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart +if($beforeService.State -notin @('Running','Stopped') -or $beforeService.StartMode -notin @('Auto','Manual','Disabled')){throw 'Stable Wecsvc state required.'} +$nonce=[guid]::NewGuid().ToString('N');$id='WELA-State-Test-'+$nonce;$description='Owned state '+([string][char]0x65e5)+([string][char]0x672c)+([string][char]0x8a9e)+' '+$nonce;$changedDescription=$description +$sid='S-1-5-21-111111111-222222222-333333333-1234' +$root=Join-Path $env:RUNNER_TEMP ('wela-wec-state-'+$nonce);$null=New-Item -ItemType Directory $root +$created=$false;$beforeIds=$null;$primary=$null;$beforeChannel=ChannelState +try { + if($beforeService.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual} + if($beforeService.State -eq 'Stopped'){Start-Service Wecsvc} + if(-not $beforeChannel.Enabled){Set-ChannelEnabled $true} + $duringChannel=ChannelState + Assert ($duringChannel.Enabled) 'Disposable fixture enabled only destination channel prerequisite' + [pscustomobject]@{Destination=$duringChannel;WinRM=(Get-CimInstance Win32_Service -Filter "Name='WinRM'"|Select-Object Name,State,StartMode);Wecsvc=(ServiceState)}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath (Join-Path $root 'fixture-prerequisites.json') -Encoding UTF8 + $beforeIds=@(Subscriptions);if($beforeIds -contains $id){throw 'Unique ID already exists.'} + $query='' + $xml=@" +$idSourceInitiated$descriptionfalsehttp://schemas.microsoft.com/wbem/wsman/1/windows/EventLogNormalfalseHTTPEventsForwardedEvents$(Get-WelaWefAuthorization @($sid)) +"@ + $xmlPath=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($xmlPath,$xml);$created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$xmlPath) + $before=Read-WelaWecStateDefinition $id @($sid);$duringService=ServiceState + Assert (-not $before.Enabled -and $before.Description -ceq $description) 'Real owned disabled subscription preserves Unicode description' + Initialize-WelaWecStateNative + $missing=$false;try{$unexpected=[Wela.WecState.Edit]::new($id+'-absent');$unexpected.Dispose()}catch{$missing=$true} + Assert ($missing -and @(Subscriptions) -notcontains ($id+'-absent')) 'Native existing-only open never creates missing subscription' + $enablePlan=$null + foreach($state in @('Disabled','Enabled','Enabled','Disabled','Disabled')){ + $index=$count;$out=Join-Path $root ("plan-$index") + $prior=Read-WelaWecStateDefinition $id @($sid) + $plan=Invoke-Cli -Arguments @('-WecStateId',$id,'-WecStateSourceSid',$sid,'-WecStateDesired',$state) -Output $out + Assert ($plan.Status -eq 'ReviewRequired' -and -not $plan.NativeSaveAttempted) 'Public plan never changes Enabled' + Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $prior.WholeKey) 'Plan preserved complete native subscription' + $planPath=Join-Path $out 'plan.json' + $apply=Invoke-Cli -Arguments @('-WecStateAction','Apply','-WecStatePlanPath',$planPath,'-WecStatePlanHash',$plan.PlanHash) -Output (Join-Path $root ("apply-$index")) + $expected=($state -eq 'Enabled');$changed=($prior.Enabled -ne $expected) + Assert ($apply.NativeSaveAttempted -eq $changed -and $apply.Status -eq $(if($changed){'StateChangedAndVerified'}else{'AlreadyMatches'})) 'Only an actual state transition invokes EcSaveSubscription' + $after=Read-WelaWecStateDefinition $id @($sid) + Assert ($after.Enabled -eq $expected -and $after.PreservedKey -ceq $before.PreservedKey) 'Native readback differs only in Enabled' + Assert ($apply.ReadyRuleCredit -eq 0 -and $apply.BookmarkContinuity -eq 'Not established' -and $null -ne $apply.RuntimeAfter) 'Separate native runtime observation supplies no delivery or bookmark claim' + foreach($artifact in $apply.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $apply.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Saved native artifacts match hashes'} + if($changed -and $expected){ + $enablePlan=$plan + $stale=Invoke-Cli -Arguments @('-WecStateAction','Apply','-WecStatePlanPath',$planPath,'-WecStatePlanHash',$plan.PlanHash) -Output (Join-Path $root 'stale-enabled-plan') -Success $false + Assert ($stale.Status -eq 'Refused' -and -not $stale.NativeSaveAttempted -and $stale.Diagnostic -match 'differs') 'A completed transition cannot replay its stale pre-state' + Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $after.WholeKey) 'Stale plan refusal preserved enabled definition' + } + } + Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $before.WholeKey) 'Explicit disable restored entire original native definition' + # A separately opened native handle sees a changed description and refuses save. + $edit=New-WelaWecStateEdit $before + try { + $null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift'))) + $refused=$false;try{$edit.Save($true)}catch{$refused=$true} + Assert ($refused -and -not $edit.SaveAttempted) 'Fresh native handle guards description drift before saving' + Assert (-not(Read-WelaWecStateDefinition $id @($sid)).Enabled) 'Native drift refusal did not enable subscription' + }finally{$edit.Dispose();$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))} + Assert ((Read-WelaWecStateDefinition $id @($sid)).WholeKey -ceq $before.WholeKey) 'Native drift fixture restored original description' + Assert ((Key (ChannelState)) -ceq (Key $duringChannel)) 'Product command preserved complete channel configuration' + Assert ((Key (ServiceState)) -ceq (Key $duringService)) 'Product command preserved service state/startup' + Write-Host "Native WEC state passed $count assertions on $([Environment]::OSVersion.Version), PowerShell $($PSVersionTable.PSVersion). No real source, listener or bookmark claim." +}catch{$primary=$_} +finally { + $errors=@() + try { + if($created -and @(Subscriptions) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$doc.DocumentElement.NamespaceURI);$observed=$doc.SelectSingleNode('/s:Subscription/s:Description',$ns).InnerText;if($observed -cnotin @($description,$changedDescription)){throw 'Fixture ownership changed; refusing deletion.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)} + if($null -ne $beforeIds -and (Key @($beforeIds|Sort-Object)) -cne (Key @(Subscriptions|Sort-Object))){throw 'Subscription inventory differs after cleanup.'} + }catch{$errors+=$_.Exception.Message} + try { + if((ChannelState).Enabled -ne $beforeChannel.Enabled){Set-ChannelEnabled $beforeChannel.Enabled} + if((Key (ChannelState)) -cne (Key $beforeChannel)){throw 'Original destination channel configuration differs.'} + }catch{$errors+=$_.Exception.Message} + try { + if($beforeService.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc} + if($beforeService.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled} + if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}} + if((Key (ServiceState)) -cne (Key $beforeService) -or (Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){throw 'Original Wecsvc state/startup differs.'} + }catch{$errors+=$_.Exception.Message} + if($errors.Count){throw "Fixture cleanup failed; retained $root : $($errors -join '; '); primary failure: $primary"} + [pscustomobject]@{Passed=($null -eq $primary);Assertions=$count;OriginalSubscriptionsRestored=$true;OriginalServiceRestored=$true;OriginalChannelRestored=$true;Computer=[Environment]::MachineName;Engine=$PSVersionTable.PSVersion.ToString();Scope='Owned native Enabled transitions only; no real source, listener, forwarding or bookmark proof'}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root 'acceptance.json') -Encoding UTF8 + Write-Host 'Original subscription inventory and Wecsvc state/startup restored.' +} +if($primary){throw $primary} +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 6a049ae4..7db4ba08 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -9,6 +9,12 @@ - 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security) +- `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security) + +- 既存のWindows標準ソース開始型購読1件のEnabledだけをレビュー後に変更する`wec-state`のPlan/Applyを追加しました。送信元認可と完全な定義、実行者・ログオン・トークン、永続的な変更前記録とネイティブ読戻しを確認して他の設定を保持し、既に一致する状態では保存・再有効化を行いません。稼働状況の観測と配送・ブックマークの継続性を区別し、使い捨てWindowsテストで実際の有効/無効切替と所有リソース・サービス状態の復元を確認します。 (関連 #368) (@Shirofune-Security) + +- 強化プロファイルのオプション IPsec Main Mode 監査に、Windows ネイティブの前提条件確認を追加しました。有効なポリシーストアのルールと現在の関連付けを読み取り、適用可能・確認範囲内で未観測・不明を区別します。共有設定処理は書き込み直前に再確認し、明示的な選択とカスタムプロファイルの指定を保持します。Server 2022/2025・PowerShell 5.1/7 の一時ルールを使ったテストで監査ポリシーの復元を確認します。ネゴシエーション、イベント生成、Sigma の対応は保証しません。 (#370) (@Shirofune-Security) + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 825c81f5..df398a78 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -9,6 +9,12 @@ - Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security) +- Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security) + +- Added reviewed `wec-state` Plan/Apply for the Enabled flag of one existing native source-initiated subscription. Exact authorization and complete definition checks, operator/logon/token guards, durable pending evidence and native readback preserve other settings; matching states never save or reactivate. Runtime remains separate, and interrupted delivery/bookmark continuity require multi-host validation. Disposable Windows lifecycle tests restore owned resources and service state. (Related #368) (@Shirofune-Security) + +- Added native prerequisite evidence for the stronger profile's optional IPsec Main Mode auditing. Effective-store rule and current association observations distinguish scoped applicability, absence and unknown results; both shared configuration paths recheck before writing and preserve explicit selection/custom-profile intent. Native disposable-rule tests cover Server 2022/2025 and PowerShell 5.1/7 with exact audit-policy restoration, without negotiation/event or Sigma claims. (#370) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security)