diff --git a/.gitattributes b/.gitattributes index dce06776..190bc45a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -63,3 +63,8 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /tests/Capi2Probe* text eol=lf /scripts/CustomAuditProfiles.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 528c7e26..08002a32 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/capi2-probe.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/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, ./docs/wec-ingress.md, ./docs/capi2-probe.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-ingress.yml b/.github/workflows/wec-ingress.yml new file mode 100644 index 00000000..23bbc6d1 --- /dev/null +++ b/.github/workflows/wec-ingress.yml @@ -0,0 +1,49 @@ +name: Reviewed collector firewall ingress +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wec-ingress: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Fixtures and public guards in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WefFirewallAddress.Tests.ps1 + ./tests/WecIngress.Tests.ps1 + ./tests/WecIngress.Cli.Tests.ps1 + - name: Native scoped firewall rule in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WecIngress.Windows.Tests.ps1 -AllowDisposableFirewallRule + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WefFirewallAddress.Tests.ps1 + ./tests/WecIngress.Tests.ps1 + ./tests/WecIngress.Cli.Tests.ps1 + - name: Native scoped firewall rule in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecIngress.Windows.Tests.ps1 -AllowDisposableFirewallRule + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: wec-ingress-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-ingress-*/ + if-no-files-found: warn + retention-days: 7 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/.github/workflows/wef-deployment.yml b/.github/workflows/wef-deployment.yml index b81482c3..ea217ea8 100644 --- a/.github/workflows/wef-deployment.yml +++ b/.github/workflows/wef-deployment.yml @@ -15,7 +15,9 @@ jobs: - name: Safe public command fixtures in Windows PowerShell 5.1 shell: powershell timeout-minutes: 3 - run: ./tests/WefDeployment.Tests.ps1 + run: | + ./tests/WefFirewallAddress.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 - name: Public CLI rejection checks in Windows PowerShell 5.1 shell: powershell timeout-minutes: 3 @@ -23,7 +25,9 @@ jobs: - name: Safe public command fixtures in PowerShell 7 shell: pwsh timeout-minutes: 3 - run: ./tests/WefDeployment.Tests.ps1 + run: | + ./tests/WefFirewallAddress.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 - name: Public CLI rejection checks in PowerShell 7 shell: pwsh timeout-minutes: 3 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index cb81fee3..d4c50975 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -5,6 +5,15 @@ **改善:** - 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 + +- `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・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 8a1cc20c..29588a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,16 @@ **Improvements:** -- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. +- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. + +- Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@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 e90ecf5f..74e38d78 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, @@ -115,6 +117,13 @@ [string]$RecoveryOutputPath, [string]$ArrivalProbePath, [string]$ArrivalOutputPath, + [ValidateSet('Plan','Apply')][string]$WecIngressAction = 'Plan', + [string]$WecIngressName, + [string[]]$WecIngressLocalAddress, + [string[]]$WecIngressRemoteAddress, + [string]$WecIngressPlanPath, + [string]$WecIngressPlanHash, + [string]$WecIngressOutputPath, [ValidateSet('Plan','Apply')][string]$WecUpdateAction = 'Plan', [string]$WecUpdateId, [string[]]$WecUpdateSourceSid, @@ -123,6 +132,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', @@ -159,6 +175,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") @@ -186,6 +203,8 @@ 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/WecIngress.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") @@ -528,7 +547,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.' } @@ -542,6 +561,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 @@ -1927,6 +1949,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 @@ -1965,8 +1989,10 @@ Usage: ./WELA.ps1 score -Help # Separate configuration compliance and evidence-qualified readiness ./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 wec-ingress -Help # Review scoped collector firewall rule creation + ./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 capi2-probe -Help # Fixed offline chain and matched CAPI2 event11 evidence + ./WELA.ps1 capi2-probe -Help # Fixed offline chain and matched CAPI2 event 11 evidence ./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 ./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector @@ -1983,6 +2009,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.'} @@ -2042,6 +2070,10 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) { if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' } } +if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'} +if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress 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) { @@ -2126,6 +2158,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 @@ -2244,6 +2277,23 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'wec-ingress' { + if ($Help) {Write-Host 'Usage: wec-ingress [-WecIngressAction Plan] -WecIngressName WELA-WEC-name -WecIngressLocalAddress IPv4 -WecIngressRemoteAddress IPv4/CIDR -WecIngressOutputPath new-directory; then Apply with -WecIngressPlanPath plan.json -WecIngressPlanHash SHA256 -WecIngressOutputPath new-directory. Creates one new Domain TCP5985 rule. See docs/wec-ingress.md.';return} + $arguments=@{Action=$WecIngressAction;OutputPath=$WecIngressOutputPath} + $map=@{WecIngressName='Name';WecIngressLocalAddress='LocalAddress';WecIngressRemoteAddress='RemoteAddress';WecIngressPlanPath='PlanPath';WecIngressPlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaWecIngress @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} @@ -2430,6 +2480,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-ingress.md b/docs/wec-ingress.md new file mode 100644 index 00000000..47662463 --- /dev/null +++ b/docs/wec-ingress.md @@ -0,0 +1,44 @@ +# Reviewed collector firewall ingress + +`wec-ingress` creates one new local Windows Firewall rule for a prepared Windows Event Collector. It addresses the collector ingress portion of #368. It is optional and separate from source configuration, subscription installation and `wec-update`. + +The command supports elevated native 64-bit Windows Server 2022/2025 standalone or member servers. The Domain firewall must already be enabled, permit inbound/local rules, and have running BFE/MpsSvc services. WinRM and Wecsvc must be installed; the command does not start them. Domain membership and an active Domain network are not required for preparation, but an inactive Domain profile means the new rule does not currently allow traffic. Domain controllers and Windows clients are outside this command's initial support. + +## Review and apply + +Run in the same elevated operator logon on the collector, from the same WELA checkout. Replace the example local address with an address actually assigned to the collector and choose remote source scopes appropriate for your network: + +```powershell +./WELA.ps1 wec-ingress -WecIngressName WELA-WEC-BranchSources ` + -WecIngressLocalAddress 10.20.30.40 -WecIngressRemoteAddress 10.20.40.0/24 ` + -WecIngressOutputPath C:\WelaEvidence\ingress-plan + +# Inspect plan.json, including every address and the actual host/profile context. +# Supply the PlanHash printed by Plan after reviewing that exact file. +./WELA.ps1 wec-ingress -WecIngressAction Apply ` + -WecIngressPlanPath C:\WelaEvidence\ingress-plan\plan.json ` + -WecIngressPlanHash '' ` + -WecIngressOutputPath C:\WelaEvidence\ingress-apply +``` + +The parent evidence directory must exist; each output directory must be new on a local fixed drive. Output is protected for the operator, Administrators and SYSTEM. Plans are strict, size-bounded JSON and SHA256 binds their exact bytes. SHA256 is an integrity comparison, not a signature or independent authorization. + +Select 1–8 exact local IPv4 addresses currently in Preferred state and 1–16 remote IPv4 literals or aligned `/24`–`/32` CIDRs. The initial implementation deliberately restricts scope size. It rejects wildcard/DNS/range/IPv6 addresses, host bits in networks, duplicate canonical addresses, loopback, unspecified and multicast/reserved destinations. Expand future address support with native validation rather than editing a generated plan. + +The fixed rule is enabled, inbound Allow, Domain profile only, TCP local port 5985, remote port Any, with exactly the reviewed local/remote addresses. Edge traversal and block-rule override are disabled. The rule has no application, service, user or machine filter, so it also permits other HTTP/WinRM uses of port 5985 within that scope. `Authentication=NotRequired` and `Encryption=NotRequired` describe the new firewall rule's IPsec criteria; they do not modify WinRM authentication, transport or encryption settings. + +The name must begin `WELA-WEC-`. It must be absent from both PersistentStore and ActiveStore, checked again immediately before native creation. The command never updates an existing rule. Windows duplicate-name rejection protects against a competing local creation. A Group Policy refresh or a later policy change can still supersede a local rule; configuration verification is a point-in-time observation, not a lock or persistence guarantee. + +## Evidence and failure handling + +Plan reads actual host/build/patch, MachineGuid, elevated operator SID/logon/groups, firewall profiles, assigned IPv4 addresses and service state, plus implementation hashes. Apply requires the same context, writes and flushes a Pending receipt before mutation, rechecks inputs, then uses `New-NetFirewallRule` once. It verifies PersistentStore and ActiveStore rule properties and all associated native filter classes. Native dotted netmasks are canonicalized for comparison. The existing `wec-collector` ingress prerequisite also compares explicit IP/network identities, so the same reviewed `/24` configuration recognizes Windows' dotted-netmask readback. Its existing broader IPv4/IPv6 CIDR support is preserved; this does not expand the narrower address selection of `wec-ingress`. Different networks, prefixes, IPv6 scope IDs, extra addresses, dynamic aliases and malformed masks remain mismatches or unreadable evidence. Raw native address strings remain in the reports. + +`CreatedAndVerified` means the new rule's selected properties and filters matched during readback. `Refused` means no create attempt was made. `CreateAttemptedUnverified` means a rule may have been created: retain the Pending receipt and any after-state artifacts, inspect the named rule and correct or remove it explicitly. There is no automatic rollback or reuse of an existing rule, and replay of an applied plan is refused. Evidence filesystem failures are fatal and may leave only the already-flushed Pending receipt. + +Other existing rules may allow broader access. This command does not claim that the collector's overall exposure is restricted to these addresses. It creates no WinRM listener, subscription, source GPO or service configuration and performs no network probe. Use the existing collector/source prerequisite and arrival checks separately. It grants zero Sigma readiness credit. Sysmon is excluded. + +## Validation + +Portable tests cover strict address/plan validation, source/context/hash drift, duplicate names, durable-before-write ordering, broader readback and partial failures. Public CLI guards reject unrelated options. The disposable Windows matrix uses Server 2022/2025 and PowerShell 5.1/7, an actual assigned local address and documentation remote subnet `192.0.2.0/24`; it exercises public Plan/Apply, native collision rejection, both policy stores and replay, checks that the real existing collector prerequisite accepts the reviewed `/24` and rejects `/25`, then removes only its uniquely named test rule and checks original rule properties, profiles and services. This is configuration evidence, not packet, listener or WEF delivery evidence. + +References: [Microsoft New-NetFirewallRule](https://learn.microsoft.com/en-us/powershell/module/netsecurity/new-netfirewallrule?view=windowsserver2025-ps), [Get-NetFirewallRule and associated filters](https://learn.microsoft.com/en-us/powershell/module/netsecurity/get-netfirewallrule?view=windowsserver2025-ps), [firewall security filters](https://learn.microsoft.com/en-us/powershell/module/netsecurity/get-netfirewallsecurityfilter?view=windowsserver2025-ps). 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/docs/wef-deployment.md b/docs/wef-deployment.md index b1efb7f0..41c9e5e0 100644 --- a/docs/wef-deployment.md +++ b/docs/wef-deployment.md @@ -40,7 +40,7 @@ On a domain controller, BUILTIN group membership has domain/AD authority rather The local host must be a domain member server whose observed DNS name equals `CollectorFqdn`. WinRM and Wecsvc can be set to Automatic and started. WELA never runs `winrm quickconfig`, `wecutil qc` or `Enable-PSRemoting`, and never creates or broadens listeners/firewall rules. -Before enabling ForwardedEvents or creating a subscription, WELA requires one existing listener matching `ListenerAddress`, HTTP, enabled state, port 5985 and URL prefix `wsman`; one named effective ActiveStore ingress rule matching inbound Allow, Domain profile, TCP 5985 and the exact `IngressLocalAddresses`/`IngressRemoteAddresses`; running Automatic services; enabled collector Kerberos; and the two assessed ASD hardening settings below. Supply explicit IP/CIDR address lists, not `Any` or `/0`. The check verifies the selected definitions, not actual packet acceptance, reachability, profile activation or the absence of other broad rules. Listener/rule evidence is retained in JSON. +Before enabling ForwardedEvents or creating a subscription, WELA requires one existing listener matching `ListenerAddress`, HTTP, enabled state, port 5985 and URL prefix `wsman`; one named effective ActiveStore ingress rule matching inbound Allow, Domain profile, TCP 5985 and the exact `IngressLocalAddresses`/`IngressRemoteAddresses`; running Automatic services; enabled collector Kerberos; and the two assessed ASD hardening settings below. Supply explicit IP/CIDR address lists, not `Any` or `/0`. The check verifies the selected definitions, not actual packet acceptance, reachability, profile activation or the absence of other broad rules. Listener/rule evidence is retained in JSON. Explicit address scopes are compared by IP/network identity, recognizing native IPv4 dotted-netmask spelling and equivalent IPv6 compression while preserving network size, address family and scope ID. Raw native strings remain visible; dynamic aliases, malformed masks and different scopes never become an ingress match. `Hardening: "ApplyASD"` explicitly permits setting `WSMan:\localhost\Service\Auth\CbtHardeningLevel` to `Strict` and `WSMan:\localhost\Shell\AllowRemoteShellAccess` to `false`. Disabling remote shells prevents new remote-shell sessions; review this on a dedicated collector using local/out-of-band administration. `AssessOnly` records unmet hardening and blocks subscription creation. Policy-owned mismatches are refused; WELA does not rewrite their controlling GPO. No Basic, CredSSP, TrustedHosts, authentication fallback or firewall access setting is changed. 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/modules/WefSubscriptions.psm1 b/modules/WefSubscriptions.psm1 index 42aa7e9a..7f40d9d6 100644 --- a/modules/WefSubscriptions.psm1 +++ b/modules/WefSubscriptions.psm1 @@ -119,6 +119,41 @@ function ConvertFrom-WelaWefSubscription { [pscustomobject]@{ Id=$id; Xml=$doc.OuterXml; Definition=[pscustomobject]$definition; Key=($definition | ConvertTo-Json -Depth 30 -Compress); Query=$query; SourceSids=@($SourceSids | Sort-Object -Unique) } } +# Compare explicit firewall address scopes by network identity, retaining family +# and IPv6 scope ID. Windows may report IPv4 CIDR as a dotted netmask. +function ConvertTo-WelaWefFirewallAddressKey { + param([string]$Value,[switch]$Observed) + $parts=$Value -split '/';$address=$null + if($parts.Count -gt 2 -or -not [Net.IPAddress]::TryParse($parts[0],[ref]$address)){throw 'Expected an explicit firewall IP address or CIDR network.'} + $bytes=$address.GetAddressBytes();$bits=$bytes.Length*8;$prefix=$bits + if($parts.Count -eq 2){ + if($Observed -and $bytes.Length -eq 4 -and $parts[1].Contains('.')){ + if($parts[1] -notmatch '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'){throw 'Invalid observed IPv4 netmask.'} + $mask=@($parts[1].Split('.')|ForEach-Object {if([int]$_ -gt 255){throw 'Invalid observed IPv4 netmask.'};[int]$_}) + $prefix=0;$zeroSeen=$false + foreach($octet in $mask){for($bit=7;$bit -ge 0;$bit--){if(($octet -band (1 -shl $bit)) -ne 0){if($zeroSeen){throw 'Observed IPv4 netmask is not contiguous.'};$prefix++}else{$zeroSeen=$true}}} + }else{ + if($parts[1] -notmatch '^\d+$'){throw 'Expected a numeric firewall CIDR prefix.'} + $prefix=[int]$parts[1] + } + if($prefix -lt 1 -or $prefix -gt $bits){throw 'Zero or out-of-range firewall CIDR prefix is unsupported.'} + } + # Network host bits are immaterial to an explicit CIDR scope. + for($i=0;$i -lt $bytes.Length;$i++){ + $remaining=$prefix-8*$i + if($remaining -le 0){$bytes[$i]=0}elseif($remaining -lt 8){$bytes[$i]=[byte]($bytes[$i] -band (256-(1 -shl (8-$remaining))))} + } + $scope=if($bits -eq 128){'%'+$address.ScopeId}else{''} + [string]$bits+':'+([BitConverter]::ToString($bytes)).Replace('-','')+$scope+'/'+$prefix +} +function Test-WelaWefFirewallAddressSet { + param([object[]]$Expected,[object[]]$Observed) + if(-not $Expected.Count -or -not $Observed.Count){return $false} + $expectedKeys=@(foreach($value in $Expected){if($value -isnot [string]){throw 'Expected firewall address must be a string.'};ConvertTo-WelaWefFirewallAddressKey $value}) + $observedKeys=@(foreach($value in $Observed){if($value -isnot [string]){throw 'Observed firewall address must be a string.'};ConvertTo-WelaWefFirewallAddressKey $value -Observed}) + return @((Compare-Object @($expectedKeys|Sort-Object -Unique) @($observedKeys|Sort-Object -Unique))).Count -eq 0 +} + function Import-WelaWefConfig { param([string]$Path, [ValidateSet('Source','Collector')][string]$Role) $full = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path @@ -172,4 +207,4 @@ function Read-WelaWecSubscriptionXml { [Wela.WecXml.Reader]::ReadXml($Id) } -Export-ModuleMember -Function Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig +Export-ModuleMember -Function ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig diff --git a/scripts/Capi2Probe.ps1 b/scripts/Capi2Probe.ps1 index c06e5947..9a0787bc 100644 --- a/scripts/Capi2Probe.ps1 +++ b/scripts/Capi2Probe.ps1 @@ -31,7 +31,7 @@ function Get-WelaCapi2ProbeStateKey { if(@($State.Services).Count -ne 3 -or (@($State.Services.Name|Sort-Object) -join ',') -cne 'CryptSvc,EventLog,Winmgmt' -or @($State.Services|Where-Object Status -cne 'Running').Count){throw 'Required native services must already be running.'} if($State.Host.Build -notin @(20348,26100) -or $State.Host.ProductType -notin @(2,3) -or -not $State.Host.UBR -or $State.Host.Computer -cne $State.Computer){throw 'CAPI2 probe requires an observed Server 2022/2025 build and patch context.'} if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-CAPI2/Operational' -or $State.Channel.Type -cne 'Operational' -or $State.Channel.Provider -cne 'Microsoft-Windows-CAPI2' -or -not $State.Channel.SecurityDescriptor){throw 'CAPI2 Operational must already be enabled with an observed descriptor.'} - if($State.Provider.Name -cne 'Microsoft-Windows-CAPI2' -or $State.Provider.Guid -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or @($State.Provider.Event11Versions).Count -ne 1 -or $State.Provider.Event11Versions[0] -ne 0 -or $State.Channel.Name -cnotin $State.Provider.LogNames){throw 'Unreviewed CAPI2 provider or event11 schema version.'} + if($State.Provider.Name -cne 'Microsoft-Windows-CAPI2' -or $State.Provider.Guid -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or @($State.Provider.Event11Versions).Count -ne 1 -or $State.Provider.Event11Versions[0] -ne 0 -or $State.Channel.Name -cnotin $State.Provider.LogNames){throw 'Unreviewed CAPI2 provider or event 11 schema version.'} $null=Get-WelaWmiProbeTokenKey $State.Token $State|ConvertTo-Json -Depth 16 -Compress } @@ -115,7 +115,7 @@ function Test-WelaCapi2ProbeEvent { if($system.Computer.InnerText -notin $computers -or $system.Execution.GetAttribute('ProcessID') -cne [string]$Operation.ProcessId -or $system.Security.GetAttribute('UserID') -cne $Operation.BeforeToken.Sid){return $false} $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false} $data=$doc.SelectSingleNode('/e:Event/e:UserData',$ns) - # Namespace and exact paths are pinned to native event11, never a recursive name search. + # Namespace and exact paths are pinned to native event 11, never a recursive name search. if(@($data.ChildNodes|Where-Object NodeType -eq Element).Count -ne 1){return $false} $chain=$data.SelectNodes('e:CertGetCertificateChain',$ns);if($chain.Count -ne 1){return $false};$chain=$chain[0] $names=@('Certificate','ExtendedKeyUsage','URLRetrievalTimeout','Flags','ChainEngineInfo','CertificateChain','EventAuxInfo','CorrelationAuxInfo','Result') @@ -139,7 +139,7 @@ function Test-WelaCapi2ProbeEvent { function Invoke-WelaCapi2Probe { param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new Capi2ProbeOutputPath; Plan creates no files.'} - $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaOfflineCapi2ChainProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;ChannelChanges=0;StoreChanges=0;TrustPolicyChanges=0;ReadyRuleCredit=0;Scope='One fixed local ephemeral certificate-chain build and matching CAPI2 event11 only. Untrusted self-signed outcome expected; no TLS, revocation, remote, forwarding, catalog event70 or Sigma/backend validation. Sysmon excluded.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaOfflineCapi2ChainProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;ChannelChanges=0;StoreChanges=0;TrustPolicyChanges=0;ReadyRuleCredit=0;Scope='One fixed local ephemeral certificate-chain build and matching CAPI2 event 11 only. Untrusted self-signed outcome expected; no TLS, revocation, remote, forwarding, catalog event70 or Sigma/backend validation. Sysmon excluded.'} if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot} try{ $before=Get-WelaCapi2ProbeState;$report.Before=$before;$key=Get-WelaCapi2ProbeStateKey $before;$null=Get-WelaCapi2ProbeWatermark @@ -154,7 +154,7 @@ function Invoke-WelaCapi2Probe { $matches=@($batch.Xml|Where-Object {Test-WelaCapi2ProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250 }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) $report.Matches=$matches.Count - if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Expected exactly one matching CAPI2 event11 in the fixed operation interval.'} + if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Expected exactly one matching CAPI2 event 11 in the fixed operation interval.'} $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0] if((Get-WelaCapi2ProbeWatermark) -lt $operation.RecordIdBefore){throw 'CAPI2 record boundary moved backwards; continuity is unknown.'} $after=Get-WelaCapi2ProbeState;$report.After=$after;if((Get-WelaCapi2ProbeStateKey $after) -cne $key){throw 'Host, token, provider, channel or implementation changed during collection.'} 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/WecIngress.ps1 b/scripts/WecIngress.ps1 new file mode 100644 index 00000000..ccdf2fc9 --- /dev/null +++ b/scripts/WecIngress.ps1 @@ -0,0 +1,172 @@ +# Explicit creation of one new, narrowly scoped collector firewall rule. +function ConvertTo-WelaIngressAddress { + param([string]$Value,[switch]$Remote,[switch]$Observed) + if($Value -cnotmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]{1,2}|([0-9]{1,3}\.){3}[0-9]{1,3}))?$'){throw 'An exact canonical IPv4 address or remote /24-/32 network is required.'} + $parts=$Value.Split('/');$octets=$parts[0].Split('.');$number=0L + foreach($octet in $octets){if([int]$octet -gt 255 -or ([int]$octet).ToString() -cne $octet){throw 'Noncanonical IPv4 address.'};$number=($number -shl 8)+[int]$octet} + if([int]$octets[0] -in @(0,127) -or [int]$octets[0] -ge 224 -or $parts[0] -eq '169.254.0.0'){throw 'Unspecified, loopback or multicast/reserved addresses are unsupported.'} + $prefix=32 + if($parts.Count -eq 2){ + if(-not $Remote){throw 'Local addresses must be exact assigned IPv4 addresses.'} + if($parts[1].Contains('.')){ + if(-not $Observed){throw 'Use a numeric CIDR prefix.'} + $mask=0L;foreach($piece in $parts[1].Split('.')){if([int]$piece -gt 255){throw 'Invalid netmask.'};$mask=($mask -shl 8)+[int]$piece} + $prefix=0;while($prefix -lt 32 -and ($mask -band (1L -shl (31-$prefix)))){$prefix++} + $expected=if($prefix -eq 0){0L}else{(0xffffffffL -shl (32-$prefix)) -band 0xffffffffL} + if($mask -ne $expected){throw 'Noncontiguous netmask.'} + }else{$prefix=[int]$parts[1];if($prefix.ToString() -cne $parts[1]){throw 'Noncanonical prefix.'}} + if($prefix -lt 24 -or $prefix -gt 32 -or ($number -band ((1L -shl (32-$prefix))-1)) -ne 0){throw 'Remote scopes require aligned /24-/32 networks.'} + } + if($prefix -eq 32){$parts[0]}else{$parts[0]+'/'+$prefix} +} +function Get-WelaIngressSelection { + param([string]$Name,[object[]]$LocalAddresses,[object[]]$RemoteAddresses) + if($Name -cnotmatch '^WELA-WEC-[A-Za-z0-9][A-Za-z0-9-]{0,63}$'){throw 'Rule name must start WELA-WEC- and contain only letters, digits and hyphens.'} + if($LocalAddresses.Count -lt 1 -or $LocalAddresses.Count -gt 8 -or $RemoteAddresses.Count -lt 1 -or $RemoteAddresses.Count -gt 16){throw 'Select 1-8 local addresses and 1-16 remote scopes.'} + $local=@();$remote=@() + foreach($value in $LocalAddresses){if($value -isnot [string]){throw 'Address must be a string.'};$local+=ConvertTo-WelaIngressAddress $value} + foreach($value in $RemoteAddresses){if($value -isnot [string]){throw 'Address must be a string.'};$remote+=ConvertTo-WelaIngressAddress $value -Remote} + if(@($local|Select-Object -Unique).Count -ne $local.Count -or @($remote|Select-Object -Unique).Count -ne $remote.Count){throw 'Duplicate address scopes are unsupported.'} + [pscustomobject][ordered]@{Name=$Name;LocalAddresses=@($local|Sort-Object);RemoteAddresses=@($remote|Sort-Object)} +} +function Get-WelaIngressSources { + $root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/WecIngress.ps1','scripts/WecUpdate.ps1','scripts/WefArrival.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){ + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + $sources|ConvertTo-Json -Compress +} +function Get-WelaIngressContext { + $reader=Get-WelaChannelReader;$hostState=Get-WelaChannelReadHost + if(-not $reader.ElevatedAdministrator -or $hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100)){throw 'An elevated native Server 2022/2025 member or standalone collector is required.'} + $services=@();foreach($name in @('BFE','MpsSvc','WinRM','Wecsvc')){ + $found=@(Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction Stop) + if($found.Count -ne 1 -or ($name -in @('BFE','MpsSvc') -and $found[0].State -ne 'Running')){throw 'Firewall services must run and WinRM/Wecsvc must be installed.'} + $services+=[ordered]@{Name=$name;State=[string]$found[0].State;StartMode=[string]$found[0].StartMode} + } + $profiles=@(NetSecurity\Get-NetFirewallProfile -PolicyStore ActiveStore -ErrorAction Stop|Sort-Object Name|Select-Object Name,Enabled,DefaultInboundAction,DefaultOutboundAction,AllowInboundRules,AllowLocalFirewallRules) + $domain=@($profiles|Where-Object Name -eq 'Domain') + if($profiles.Count -ne 3 -or $domain.Count -ne 1 -or [string]$domain[0].Enabled -ne 'True' -or [string]$domain[0].AllowLocalFirewallRules -eq 'False' -or [string]$domain[0].AllowInboundRules -eq 'False'){throw 'Domain firewall must be enabled and permit local inbound rules.'} + $addresses=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop|Where-Object AddressState -eq 'Preferred'|ForEach-Object IPAddress|Sort-Object -Unique) + [pscustomobject][ordered]@{Host=$hostState;MachineGuid=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids};Services=$services;Profiles=$profiles;Addresses=$addresses} +} +function Read-WelaIngressRules { + param([string]$Store) + $rules=@(NetSecurity\Get-NetFirewallRule -PolicyStore $Store -ErrorAction Stop|Select-Object -First 4097) + if($rules.Count -gt 4096){throw 'Firewall inventory exceeds the 4096-rule bound.'} + $rules +} +function Assert-WelaIngressAbsent { + param([string]$Name) + foreach($store in @('PersistentStore','ActiveStore')){if(@(Read-WelaIngressRules $store|Where-Object Name -eq $Name).Count){throw 'The selected rule name already exists; existing rules are never replaced.'}} +} +function New-WelaIngressNativeRule { + param($Selection) + $null=NetSecurity\New-NetFirewallRule -PolicyStore PersistentStore -Name $Selection.Name -DisplayName $Selection.Name -Description 'WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.' -Group 'WELA reviewed collector ingress' -Enabled True -Profile Domain -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5985 -RemotePort Any -LocalAddress $Selection.LocalAddresses -RemoteAddress $Selection.RemoteAddresses -EdgeTraversalPolicy Block -LooseSourceMapping $false -LocalOnlyMapping $false -Authentication NotRequired -Encryption NotRequired -OverrideBlockRules $false -ErrorAction Stop +} +function Read-WelaIngressRule { + param([string]$Store,[string]$Name) + $rule=@(Read-WelaIngressRules $Store|Where-Object Name -eq $Name) + if($rule.Count -ne 1){throw 'Exactly one selected rule must be observed.'} + $r=$rule[0];$filters=[ordered]@{} + foreach($kind in @('Port','Address','Application','Service','Interface','InterfaceType','Security')){ + $command='NetSecurity\Get-NetFirewall'+$kind+'Filter';$items=@(&$command -AssociatedNetFirewallRule $r -ErrorAction Stop) + if($items.Count -ne 1){throw "Ambiguous $kind filter."};$filters[$kind]=$items[0] + } + [pscustomobject]@{Store=$Store;Rule=$r;Filters=$filters} +} +function ConvertTo-WelaIngressEvidence { + param($Observed) + # Project the inspected fields, not recursive CIM class/session metadata. + $fields=[ordered]@{ + Rule=@('Name','DisplayName','Description','Group','Enabled','Profile','Direction','Action','EdgeTraversalPolicy','LooseSourceMapping','LocalOnlyMapping','PolicyStoreSourceType','Owner','Platform','PrimaryStatus','EnforcementStatus') + Port=@('Protocol','LocalPort','RemotePort','IcmpType','DynamicTarget') + Address=@('LocalAddress','RemoteAddress') + Application=@('Program','Package') + Service=@('Service');Interface=@('InterfaceAlias');InterfaceType=@('InterfaceType') + Security=@('Authentication','Encryption','OverrideBlockRules','LocalUser','RemoteUser','RemoteMachine') + } + $result=[ordered]@{Store=$Observed.Store} + foreach($kind in $fields.Keys){ + $item=if($kind -eq 'Rule'){$Observed.Rule}else{$Observed.Filters[$kind]};$values=[ordered]@{} + foreach($name in $fields[$kind]){ + $property=$item.PSObject.Properties[$name] + $values[$name]=[ordered]@{Present=($null -ne $property);Value=$(if($null -eq $property -or $null -eq $property.Value){$null}else{@($property.Value|ForEach-Object {[string]$_})})} + } + $result[$kind]=$values + } + [pscustomobject]$result +} +function Assert-WelaIngressReadback { + param($Observed,$Selection) + $r=$Observed.Rule;$f=$Observed.Filters + foreach($field in @('Name','DisplayName')){if([string]$r.$field -cne $Selection.Name){throw "Rule $field differs."}} + $fixed=@{Description='WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.';Group='WELA reviewed collector ingress';Enabled='True';Profile='Domain';Direction='Inbound';Action='Allow';EdgeTraversalPolicy='Block';LooseSourceMapping='False';LocalOnlyMapping='False';PolicyStoreSourceType='Local'} + foreach($field in $fixed.Keys){if([string]$r.$field -cne $fixed[$field]){throw "Rule $field differs."}} + if($r.Owner -or @($r.Platform|Where-Object {$_}).Count){throw 'Unexpected rule owner or platform restriction.'} + if([string]$f.Port.Protocol -notin @('TCP','6') -or [string]$f.Port.LocalPort -ne '5985' -or [string]$f.Port.RemotePort -ne 'Any' -or [string]$f.Port.IcmpType -ne 'Any' -or [string]$f.Port.DynamicTarget -ne 'Any'){throw 'Port filter differs.'} + $local=@($f.Address.LocalAddress|ForEach-Object {ConvertTo-WelaIngressAddress $_}|Sort-Object) + $remote=@($f.Address.RemoteAddress|ForEach-Object {ConvertTo-WelaIngressAddress $_ -Remote -Observed}|Sort-Object) + if(($local -join '|') -cne ($Selection.LocalAddresses -join '|') -or ($remote -join '|') -cne ($Selection.RemoteAddresses -join '|')){throw 'Address filters differ.'} + foreach($pair in @(@('Application','Program'),@('Service','Service'),@('Interface','InterfaceAlias'),@('InterfaceType','InterfaceType'),@('Security','LocalUser'),@('Security','RemoteUser'),@('Security','RemoteMachine'))){if([string]$f[$pair[0]].($pair[1]) -ne 'Any'){throw "Unexpected $($pair -join '/') filter: '$($f[$pair[0]].($pair[1]))'."}} + # Native Package is a nullable SID, unlike the Program 'Any' alias. A + # present empty/null Package means no package restriction; missing is unknown. + $package=$f.Application.PSObject.Properties['Package'] + if($null -eq $package -or ($null -ne $package.Value -and ($package.Value -isnot [string] -or $package.Value -cnotin @('','Any')))){throw 'Unexpected or missing Application/Package filter.'} + if([string]$f.Security.Authentication -ne 'NotRequired' -or [string]$f.Security.Encryption -ne 'NotRequired' -or [string]$f.Security.OverrideBlockRules -ne 'False'){throw 'Security filter differs.'} +} +function Assert-WelaIngressPlan { + param($Plan) + Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Selection','ContextKey','Sources','RecordedUtc') + if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -cne 'WelaCollectorIngressPlan' -or $Plan.ContextKey -isnot [string] -or $Plan.Sources -isnot [string]){throw 'Unknown ingress plan.'} + Assert-WelaArrivalObject $Plan.Selection @('Name','LocalAddresses','RemoteAddresses') + if($Plan.Selection.Name -isnot [string] -or $Plan.Selection.LocalAddresses -isnot [array] -or $Plan.Selection.RemoteAddresses -isnot [array]){throw 'Mistyped ingress selection.'} + $null=Get-WelaIngressSelection $Plan.Selection.Name $Plan.Selection.LocalAddresses $Plan.Selection.RemoteAddresses + $null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc +} +function Invoke-WelaWecIngress { + param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Name,[string[]]$LocalAddress,[string[]]$RemoteAddress,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath) + if($Action -eq 'Plan'){ + if(-not $Name -or -not $LocalAddress -or -not $RemoteAddress -or $PlanPath -or $PlanHash){throw 'Plan requires a new rule name and explicit local/remote IPv4 scopes, without a prior plan.'} + $selection=Get-WelaIngressSelection $Name $LocalAddress $RemoteAddress;$inputFile=$null + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $Name -or $LocalAddress -or $RemoteAddress){throw 'Apply accepts only a reviewed plan path, SHA256 and new output.'} + $inputFile=Read-WelaWecUpdateFile $PlanPath + } + $ErrorActionPreference='Stop' + $sourcePath=if($inputFile){$inputFile.Path}else{Join-Path (Split-Path $PSScriptRoot -Parent) 'WELA.ps1'} + $output=New-WelaArrivalOutput $OutputPath $sourcePath + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaCollectorIngress';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeCreateAttempted=$false;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One local Domain-profile TCP5985 IPv4 allow rule only. Other rules may allow broader access; no listener, service, authentication, subscription, packet delivery or Sigma proof. Sysmon excluded.'} + try { + $context=Get-WelaIngressContext;$key=$context|ConvertTo-Json -Depth 16 -Compress;$sources=Get-WelaIngressSources + if($Action -eq 'Apply'){ + if($inputFile.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'} + $plan=ConvertFrom-WelaArrivalJson $inputFile.Text;Assert-WelaIngressPlan $plan + if($plan.ContextKey -cne $key -or $plan.Sources -cne $sources){throw 'Host, reader, firewall context or source code differs from reviewed plan.'} + $selection=Get-WelaIngressSelection $plan.Selection.Name $plan.Selection.LocalAddresses $plan.Selection.RemoteAddresses;$report.PlanHash=$inputFile.Hash + } + foreach($address in $selection.LocalAddresses){if($address -cnotin $context.Addresses){throw 'Every local address must currently be assigned and Preferred.'}} + Assert-WelaIngressAbsent $selection.Name + if($Action -eq 'Plan'){ + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaCollectorIngressPlan';Selection=$selection;ContextKey=$key;Sources=$sources;RecordedUtc=[DateTime]::UtcNow.ToString('o')};Assert-WelaIngressPlan $plan + if((Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key -or (Get-WelaIngressSources) -cne $sources){throw 'Context or code drift during planning.'} + Assert-WelaIngressAbsent $selection.Name + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0 + }else{ + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $inputFile.Text + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-create.json' ([ordered]@{Status='Pending';Selection=$selection;Context=$context;PlanHash=$PlanHash;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaIngressSources) -cne $sources -or (Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key){throw 'Plan, context or source drift immediately before creation.'} + Assert-WelaIngressAbsent $selection.Name + $report.NativeCreateAttempted=$true;New-WelaIngressNativeRule $selection + foreach($store in @('PersistentStore','ActiveStore')){ + $observed=Read-WelaIngressRule $store $selection.Name + $report.Artifacts+=Write-WelaWecUpdateArtifact $output ($store+'-after.json') ((ConvertTo-WelaIngressEvidence $observed)|ConvertTo-Json -Depth 8) + Assert-WelaIngressReadback $observed $selection + } + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaIngressSources) -cne $sources -or (Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key){throw 'Context, code or plan changed after creation.'} + $report.Status='CreatedAndVerified';$report.ExitCode=0 + } + }catch{$report.Status=if($report.NativeCreateAttempted){'CreateAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 20) + $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/scripts/WefDeployment.ps1 b/scripts/WefDeployment.ps1 index 04bb2aec..39d9597b 100644 --- a/scripts/WefDeployment.ps1 +++ b/scripts/WefDeployment.ps1 @@ -125,8 +125,8 @@ function Get-WelaWefCollectorPrerequisites { if ($rules.Count -ne 1) { throw 'Expected exactly one existing effective firewall rule.' } $rule=$rules[0]; $ports=@($rule | Get-NetFirewallPortFilter -ErrorAction Stop); $addresses=@($rule | Get-NetFirewallAddressFilter -ErrorAction Stop) $scopeMatches=$addresses.Count -eq 1 -and - (@(Compare-Object @($Config.IngressLocalAddresses | Sort-Object -Unique) @($addresses[0].LocalAddress | Sort-Object -Unique)).Count -eq 0) -and - (@(Compare-Object @($Config.IngressRemoteAddresses | Sort-Object -Unique) @($addresses[0].RemoteAddress | Sort-Object -Unique)).Count -eq 0) + (Test-WelaWefFirewallAddressSet $Config.IngressLocalAddresses @($addresses[0].LocalAddress)) -and + (Test-WelaWefFirewallAddressSet $Config.IngressRemoteAddresses @($addresses[0].RemoteAddress)) $ok=[string]$rule.Enabled -eq 'True' -and [string]$rule.Direction -eq 'Inbound' -and [string]$rule.Action -eq 'Allow' -and [string]$rule.Profile -eq 'Domain' -and $ports.Count -eq 1 -and [string]$ports[0].Protocol -in @('TCP','6') -and [string]$ports[0].LocalPort -eq '5985' -and $scopeMatches $checks += [pscustomobject]@{ Name='Existing scoped domain ingress rule'; Verified=[bool]$ok; Evidence=@{ Rule=($rule | Select-Object Name,Enabled,Direction,Action,Profile,PolicyStoreSourceType,EnforcementStatus); Ports=$ports | Select-Object Protocol,LocalPort,RemotePort; Addresses=$addresses | Select-Object LocalAddress,RemoteAddress }; Diagnostic='Exact selected rule definition only; other rules, network reachability and effective packet acceptance are not established.' } 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/WecIngress.Cli.Tests.ps1 b/tests/WecIngress.Cli.Tests.ps1 new file mode 100644 index 00000000..a628b54f --- /dev/null +++ b/tests/WecIngress.Cli.Tests.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('wec-ingress','-Help');Code=0;Pattern='TCP5985'}, + @{Args=@('configure','-WecIngressAction','Apply','-Auto');Code=1;Pattern='require wec-ingress'}, + @{Args=@('wec-ingress','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-ingress','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-ingress','-Help','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-ingress','-Help','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-ingress','-WecIngressAction','Apply','-WecIngressOutputPath','not-created');Code=1;Pattern='reviewed plan'}, + @{Args=@('wec-ingress','-WecIngressOutputPath','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 ingress CLI: $count checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/WecIngress.Tests.ps1 b/tests/WecIngress.Tests.ps1 new file mode 100644 index 00000000..2cbae66e --- /dev/null +++ b/tests/WecIngress.Tests.ps1 @@ -0,0 +1,62 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/WecIngress.ps1" +$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-ingress-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$script:mode='ok';$script:reads=0;$script:creates=0;$script:exists=$false +function Get-WelaIngressContext {[pscustomobject][ordered]@{Computer='TEST';Addresses=@('10.10.10.10');Reader='LOGON'}} +function Assert-WelaIngressAbsent {param($Name);$script:reads++;if($script:exists -or ($script:mode -eq 'race' -and $script:reads -eq 2)){throw 'already exists'}} +function New-WelaIngressNativeRule {param($Selection);Assert (Test-Path $script:journal) 'Pending receipt precedes creation';$script:creates++;if($script:mode -eq 'failure'){throw 'native failure'};$script:exists=$true} +function Read-WelaIngressRule { + param($Store,$Name) + $r=[pscustomobject]@{Name=$Name;DisplayName=$Name;Description='WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.';Group='WELA reviewed collector ingress';Enabled='True';Profile='Domain';Direction='Inbound';Action='Allow';EdgeTraversalPolicy='Block';LooseSourceMapping=$false;LocalOnlyMapping=$false;PolicyStoreSourceType='Local';Owner='';Platform=@()} + $f=[ordered]@{Port=[pscustomobject]@{Protocol='TCP';LocalPort='5985';RemotePort='Any';IcmpType='Any';DynamicTarget='Any'};Address=[pscustomobject]@{LocalAddress=@('10.10.10.10');RemoteAddress=@('192.0.2.0/255.255.255.0')};Application=[pscustomobject]@{Program='Any';Package='Any'};Service=[pscustomobject]@{Service='Any'};Interface=[pscustomobject]@{InterfaceAlias='Any'};InterfaceType=[pscustomobject]@{InterfaceType='Any'};Security=[pscustomobject]@{Authentication='NotRequired';Encryption='NotRequired';OverrideBlockRules=$false;LocalUser='Any';RemoteUser='Any';RemoteMachine='Any'}} + if($script:mode -eq 'broader'){$f.Address.RemoteAddress=@('Any')} + if($script:mode -eq 'wrong-port'){$f.Port.LocalPort='Any'} + if($script:mode -eq 'wrong-store' -and $Store -eq 'ActiveStore'){$r.PolicyStoreSourceType='GroupPolicy'} + [pscustomobject]@{Store=$Store;Rule=$r;Filters=$f} +} +try { + foreach($bad in @('Any','10.1','010.0.0.1','127.0.0.1','0.0.0.0','224.0.0.1','255.255.255.255','10.0.0.1/24','10.0.0.0/16','192.0.2.0/33','192.0.2.0/024','192.0.2.0/255.255.255.0','example.org','192.0.2.1-192.0.2.4','::1')){Reject {ConvertTo-WelaIngressAddress $bad -Remote} '.'} + Assert ((ConvertTo-WelaIngressAddress '192.0.2.0/255.255.255.0' -Remote -Observed) -eq '192.0.2.0/24') 'Observed mask canonicalized' + Reject {ConvertTo-WelaIngressAddress '192.0.2.0/255.0.255.0' -Remote -Observed} 'Noncontiguous' + Reject {Get-WelaIngressSelection 'WELA-WEC-Test' @('10.10.10.10') @('192.0.2.1','192.0.2.1/32')} 'Duplicate' + Reject {Get-WelaIngressSelection '*' @('10.10.10.10') @('192.0.2.1')} 'name' + $selection=Get-WelaIngressSelection 'WELA-WEC-Test' @('10.10.10.10') @('192.0.2.0/24') + $observation=Read-WelaIngressRule PersistentStore $selection.Name;Assert-WelaIngressReadback $observation $selection + foreach($package in @($null,'')){$observation.Filters.Application.Package=$package;Assert-WelaIngressReadback $observation $selection;$count++} + $observation.Filters.Application.Package='S-1-15-2-1';Reject {Assert-WelaIngressReadback $observation $selection} 'Package' + $observation.Filters.Application.PSObject.Properties.Remove('Package');Reject {Assert-WelaIngressReadback $observation $selection} 'Package' + $observation.Filters.Application|Add-Member NoteProperty Package 'Any' + $evidence=ConvertTo-WelaIngressEvidence $observation;Assert ($evidence.Application.Package.Present -and $evidence.Application.Package.Value -eq 'Any') 'Evidence preserves explicit inspected fields' + foreach($field in @('Enabled','Direction','Profile','Action','EdgeTraversalPolicy','LooseSourceMapping','LocalOnlyMapping','PolicyStoreSourceType','Description','Group','DisplayName','Name')){ + $saved=$observation.Rule.$field;$observation.Rule.$field='unexpected';Reject {Assert-WelaIngressReadback $observation $selection} 'differs';$observation.Rule.$field=$saved + } + foreach($scenario in @('ok','hash','context','duplicate','race','failure','broader','wrong-port','wrong-store','unassigned','replay')){ + $script:mode='ok';$script:reads=0;$script:creates=0;$script:exists=$false + $result=Invoke-WelaWecIngress Plan -Name $selection.Name -LocalAddress $selection.LocalAddresses -RemoteAddress $selection.RemoteAddresses -OutputPath (Join-Path $root ($scenario+'-plan')) + Assert ($result.Status -eq 'ReviewRequired' -and $script:creates -eq 0) "Read-only plan: $($result.Diagnostic)" + $path=Join-Path $result.OutputPath 'plan.json';$hash=$result.PlanHash + if($scenario -eq 'hash'){$hash='b'*64} + if($scenario -in @('context','duplicate','unassigned')){ + $text=[IO.File]::ReadAllText($path) + if($scenario -eq 'context'){$text=$text.Replace('TEST','OTHER')} + if($scenario -eq 'duplicate'){$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')} + if($scenario -eq 'unassigned'){$text=$text.Replace('"10.10.10.10"','"10.10.10.11"')} + [IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant() + } + $script:mode=$scenario;$script:reads=0;$out=Join-Path $root ($scenario+'-apply');$script:journal=Join-Path $out 'before-create.json' + $applied=Invoke-WelaWecIngress Apply -PlanPath $path -PlanHash $hash -OutputPath $out + Assert (($applied.ExitCode -eq 0) -eq ($scenario -in @('ok','replay'))) "Scenario $scenario : $($applied.Diagnostic)" + Assert ($applied.ReadyRuleCredit -eq 0 -and (Test-Path (Join-Path $out 'manifest.json'))) 'No detection credit; durable result' + if($scenario -in @('hash','context','duplicate','race','unassigned')){Assert ($script:creates -eq 0) 'Refused before mutation'} + if($scenario -in @('failure','broader','wrong-port','wrong-store')){Assert ($applied.Status -eq 'CreateAttemptedUnverified' -and $script:creates -eq 1) 'Unverified possible creation retained'} + if($scenario -eq 'replay'){$again=Invoke-WelaWecIngress Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root 'replay-again');Assert ($again.Status -eq 'Refused' -and $script:creates -eq 1) 'Existing rule never overwritten'} + } +}finally{Remove-Item $root -Recurse -Force} +Write-Host "WEC ingress tests passed: $count assertions." diff --git a/tests/WecIngress.Windows.Tests.ps1 b/tests/WecIngress.Windows.Tests.ps1 new file mode 100644 index 00000000..0fb2b104 --- /dev/null +++ b/tests/WecIngress.Windows.Tests.ps1 @@ -0,0 +1,75 @@ +param([switch]$AllowDisposableFirewallRule) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableFirewallRule -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +. "$repo/scripts/WefDeployment.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/ChannelRead.ps1" +. "$repo/scripts/WecIngress.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 RulesKey {param([string]$Exclude);Key @(Read-WelaIngressRules PersistentStore|Where-Object Name -ne $Exclude|Sort-Object Name|Select-Object Name,DisplayName,Description,Group,Enabled,Profile,Direction,Action,EdgeTraversalPolicy,LooseSourceMapping,LocalOnlyMapping,Owner)} +$engine=(Get-Process -Id $PID).Path +function Invoke-IngressFixtureCli {param([string[]]$Arguments,[int]$Expected=0) + $old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0)){throw "CLI $code : $($lines -join ' ')"} +} +$context=Get-WelaIngressContext;$contextKey=Key $context;$beforeRules=RulesKey '' +$local=@($context.Addresses|Where-Object {$_ -notlike '127.*' -and $_ -notlike '169.254.*'})[0] +if(-not $local){throw 'An assigned nonloopback IPv4 address is required.'} +$name='WELA-WEC-Test-'+[guid]::NewGuid().ToString('N');$selection=Get-WelaIngressSelection $name @($local) @('192.0.2.0/24') +$root=Join-Path $env:RUNNER_TEMP ('wela-ingress-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$primary=$null;$attempted=$false +try { + Assert-WelaIngressAbsent $name + Invoke-IngressFixtureCli @('wec-ingress','-WecIngressName',$name,'-WecIngressLocalAddress',$local,'-WecIngressRemoteAddress','192.0.2.0/24','-WecIngressOutputPath',"$root/plan") + $plan=Get-Content "$root/plan/manifest.json" -Raw|ConvertFrom-Json + Assert ($plan.Status -eq 'ReviewRequired' -and -not $plan.NativeCreateAttempted) 'Public Plan is read only' + Assert ((RulesKey '') -ceq $beforeRules) 'Planning preserves persistent rule inventory and properties' + $attempted=$true + Invoke-IngressFixtureCli @('wec-ingress','-WecIngressAction','Apply','-WecIngressPlanPath',"$root/plan/plan.json",'-WecIngressPlanHash',$plan.PlanHash,'-WecIngressOutputPath',"$root/apply") + $apply=Get-Content "$root/apply/manifest.json" -Raw|ConvertFrom-Json + Assert ($apply.Status -eq 'CreatedAndVerified' -and $apply.NativeCreateAttempted -and $apply.ReadyRuleCredit -eq 0) 'Actual public creation and readback' + foreach($store in @('PersistentStore','ActiveStore')){Assert-WelaIngressReadback (Read-WelaIngressRule $store $name) $selection;$count++} + Assert ((RulesKey $name) -ceq $beforeRules) 'Other persistent rule properties preserved' + # Exercise the real existing collector prerequisite against the new native rule. + # Other prerequisites may be unmet on this standalone fixture; inspect only ingress. + $collectorConfig=[pscustomobject]@{CollectorFqdn=(Get-WelaWefHost).Fqdn;ListenerAddress='*';IngressRuleName=$name;IngressLocalAddresses=@($local);IngressRemoteAddresses=@('192.0.2.0/24')} + $collectorChecks=@(Get-WelaWefCollectorPrerequisites $collectorConfig) + $ingress=@($collectorChecks|Where-Object Name -eq 'Existing scoped domain ingress rule') + Assert ($ingress.Count -eq 1 -and $ingress[0].Verified) 'Existing collector prerequisite accepts the same reviewed CIDR after native dotted-netmask readback' + $null=Write-WelaWecUpdateArtifact $root 'collector-ingress-check.json' ($ingress[0]|ConvertTo-Json -Depth 8) + $collectorConfig.IngressRemoteAddresses=@('192.0.2.0/25') + $mismatch=@(Get-WelaWefCollectorPrerequisites $collectorConfig|Where-Object Name -eq 'Existing scoped domain ingress rule') + Assert ($mismatch.Count -eq 1 -and -not $mismatch[0].Verified) 'Collector prerequisite refuses a genuinely different approved network' + $null=Write-WelaWecUpdateArtifact $root 'collector-ingress-mismatch.json' ($mismatch[0]|ConvertTo-Json -Depth 8) + # Native New must not replace an existing name, even if a creator races our last absence check. + $collision=$false;try{New-WelaIngressNativeRule $selection}catch{$collision=$true} + Assert $collision 'Native duplicate-name creation refuses replacement' + Assert-WelaIngressReadback (Read-WelaIngressRule PersistentStore $name) $selection + Invoke-IngressFixtureCli @('wec-ingress','-WecIngressAction','Apply','-WecIngressPlanPath',"$root/plan/plan.json",'-WecIngressPlanHash',$plan.PlanHash,'-WecIngressOutputPath',"$root/replay") 1 + $replay=Get-Content "$root/replay/manifest.json" -Raw|ConvertFrom-Json + Assert ($replay.Status -eq 'Refused' -and -not $replay.NativeCreateAttempted) 'Plan replay refuses an existing rule' + Assert ((Key (Get-WelaIngressContext)) -ceq $contextKey) 'Profiles, services, host and address context unchanged' + Write-Host "Native WEC ingress passed $count assertions on $([Environment]::OSVersion.Version), PowerShell $($PSVersionTable.PSVersion). No listener or traffic created." +}catch{ + $primary=$_ + foreach($store in @('PersistentStore','ActiveStore')){try{$snapshot=ConvertTo-WelaIngressEvidence (Read-WelaIngressRule $store $name);Write-Host ($snapshot|ConvertTo-Json -Depth 8)}catch{Write-Host "Diagnostic read $store : $($_.Exception.Message)"}} +} +finally { + $errors=@() + try { + $owned=@(Read-WelaIngressRules PersistentStore|Where-Object Name -eq $name) + if($owned.Count){if(-not $attempted -or $owned.Count -ne 1 -or $owned[0].Group -cne 'WELA reviewed collector ingress' -or $owned[0].DisplayName -cne $name){throw 'Fixture ownership is ambiguous; refusing removal.'};$owned[0]|NetSecurity\Remove-NetFirewallRule -ErrorAction Stop} + Assert-WelaIngressAbsent $name + if((RulesKey '') -cne $beforeRules -or (Key (Get-WelaIngressContext)) -cne $contextKey){throw 'Original rule inventory, service or profile state differs after cleanup.'} + }catch{$errors+=$_.Exception.Message} + if($errors.Count){throw "Fixture cleanup failed: $($errors -join '; '); primary: $primary; evidence: $root"} + Write-Host 'Owned rule removed; original persistent rules, firewall profiles and services preserved.' +} +if($primary){throw $primary} +$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/tests/WefDeployment.Tests.ps1 b/tests/WefDeployment.Tests.ps1 index ebb74a95..fba71b03 100644 --- a/tests/WefDeployment.Tests.ps1 +++ b/tests/WefDeployment.Tests.ps1 @@ -78,7 +78,7 @@ function Get-WSManInstance { } function Get-NetFirewallRule { param($Name,$PolicyStore) Assert ($PolicyStore -eq 'ActiveStore') 'Ingress is read from effective ActiveStore'; [pscustomobject]@{ Name=$Name; Enabled=$global:WelaWefFixture.Ingress; Direction='Inbound'; Action='Allow'; Profile='Domain'; PolicyStoreSourceType='Local'; EnforcementStatus='Full' } } function Get-NetFirewallPortFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ Protocol='TCP'; LocalPort='5985'; RemotePort='Any' } } } -function Get-NetFirewallAddressFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ LocalAddress=@('192.0.2.10'); RemoteAddress=@('192.0.2.0/24') } } } +function Get-NetFirewallAddressFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ LocalAddress=@('192.0.2.10/255.255.255.255'); RemoteAddress=@('192.0.2.0/255.255.255.0') } } } function Read-Host { param($Prompt) return $global:WelaWefFixture.Prompt } function Invoke-WelaNative { param($FilePath,$Arguments) diff --git a/tests/WefFirewallAddress.Tests.ps1 b/tests/WefFirewallAddress.Tests.ps1 new file mode 100644 index 00000000..c9d4a445 --- /dev/null +++ b/tests/WefFirewallAddress.Tests.ps1 @@ -0,0 +1,42 @@ +# Scope comparison only; no firewall or Windows setting mutation. +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Code){$caught=$false;try{&$Code|Out-Null}catch{$caught=$true};Assert $caught 'Malformed, broad or non-IP observed scope must be refused.'} +foreach($pair in @( + @('192.0.2.0/24','192.0.2.0/255.255.255.0'), + @('10.0.0.0/8','10.0.0.0/255.0.0.0'), + @('192.0.2.1','192.0.2.1/255.255.255.255'), + @('192.0.2.1/32','192.0.2.1'), + @('192.0.2.128/25','192.0.2.128/255.255.255.128'), + @('192.0.2.129/25','192.0.2.128/255.255.255.128'), + @('2001:0DB8:0000:0000::/64','2001:db8::/64'), + @('2001:db8::1/128','2001:0db8::1'), + @('2001:db8::1/64','2001:db8::/64'), + @('fe80::1%3','fe80:0:0:0:0:0:0:1%3') +)){ + Assert (Test-WelaWefFirewallAddressSet @($pair[0]) @($pair[1])) ('Equivalent scopes compare equal: '+($pair -join ' / ')) +} +foreach($pair in @( + @('192.0.2.0/24','192.0.2.0/255.255.254.0'), + @('192.0.2.0/24','192.0.2.0/255.255.255.128'), + @('192.0.2.0/24','198.51.100.0/255.255.255.0'), + @('192.0.2.1','192.0.2.2'), + @('2001:db8::/64','2001:db8::/63'), + @('2001:db8::/64','2001:db8:0:1::/64'), + @('192.0.2.1','::ffff:192.0.2.1'), + @('fe80::1%3','fe80::1%4') +)){ + Assert (-not(Test-WelaWefFirewallAddressSet @($pair[0]) @($pair[1]))) ('Different scope is refused: '+($pair -join ' / ')) +} +Assert (Test-WelaWefFirewallAddressSet @('2001:db8::/64','192.0.2.0/24') @('192.0.2.0/255.255.255.0','2001:0db8::/64')) 'Unordered mixed IPv4/IPv6 scopes remain equivalent.' +Assert (-not(Test-WelaWefFirewallAddressSet @('192.0.2.1') @('192.0.2.1','192.0.2.2'))) 'Extra native scope is not a match.' +Assert (-not(Test-WelaWefFirewallAddressSet @('192.0.2.1','192.0.2.2') @('192.0.2.1'))) 'Missing native scope is not a match.' +Assert (-not(Test-WelaWefFirewallAddressSet @('192.0.2.1') @())) 'Empty native scope is unknown, never Any.' +foreach($value in @('Any','LocalSubnet','example.org','192.0.2.1-192.0.2.10','192.0.2.0/0','192.0.2.0/0.0.0.0','192.0.2.0/255.0.255.0','192.0.2.0/255.255.999.0','192.0.2.0/255.255.0','192.0.2.0/33','::/0','::1/129','2001:db8::/255.255.255.0','192.0.2.1/24/32','')){Reject {Test-WelaWefFirewallAddressSet @('192.0.2.0/24') @($value)}} +Reject {Test-WelaWefFirewallAddressSet @('192.0.2.0/255.255.255.0') @('192.0.2.0/24')} +Reject {Test-WelaWefFirewallAddressSet @(1) @('192.0.2.1')} +Reject {Test-WelaWefFirewallAddressSet @('192.0.2.1') @(1)} +Write-Host "WEF firewall address comparison: $count assertions passed." diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index e51895c2..508b59bb 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -8,6 +8,15 @@ **改善:** - 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。 + +- `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・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 53c32037..b1894bab 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,16 @@ **Improvements:** -- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. +- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed. + +- Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@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)