From 203fdfc9429a683b67471f56ed867d757508d73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E4=B8=AD=E3=82=B6=E3=83=83=E3=82=AF=20Isaac=20Ma?= =?UTF-8?q?this?= <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:19:30 +0900 Subject: [PATCH] Add reviewed scoped collector firewall ingress creation (#442) * Add reviewed scoped collector firewall ingress creation * Avoid Windows Clear-Item alias in native ingress fixture * Handle native nullable package scope and retain bounded filter evidence * Match native firewall network spelling in collector prerequisites --- .github/workflows/release.yml | 2 +- .github/workflows/wec-ingress.yml | 49 +++++++ .github/workflows/wef-deployment.yml | 8 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 19 +++ docs/wec-ingress.md | 44 +++++++ docs/wef-deployment.md | 2 +- modules/WefSubscriptions.psm1 | 37 +++++- scripts/WecIngress.ps1 | 172 +++++++++++++++++++++++++ scripts/WefDeployment.ps1 | 4 +- tests/WecIngress.Cli.Tests.ps1 | 15 +++ tests/WecIngress.Tests.ps1 | 62 +++++++++ tests/WecIngress.Windows.Tests.ps1 | 75 +++++++++++ tests/WefDeployment.Tests.ps1 | 2 +- tests/WefFirewallAddress.Tests.ps1 | 42 ++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 18 files changed, 533 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/wec-ingress.yml create mode 100644 docs/wec-ingress.md create mode 100644 scripts/WecIngress.ps1 create mode 100644 tests/WecIngress.Cli.Tests.ps1 create mode 100644 tests/WecIngress.Tests.ps1 create mode 100644 tests/WecIngress.Windows.Tests.ps1 create mode 100644 tests/WefFirewallAddress.Tests.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3565e18b..c74297b3 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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md -Destination release-binaries/docs/ + 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 -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true 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/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 ea8a038c..c85e4a21 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- `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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b43334..2933c992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- 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) diff --git a/WELA.ps1 b/WELA.ps1 index aeff7338..26475edf 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -114,6 +114,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, @@ -192,6 +199,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorA Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/WefDeployment.ps1") . (Join-Path $ScriptRoot "scripts/WecUpdate.ps1") +. (Join-Path $ScriptRoot "scripts/WecIngress.ps1") . (Join-Path $ScriptRoot "scripts/WecState.ps1") . (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1") . (Join-Path $ScriptRoot "scripts/AuditScoring.ps1") @@ -1977,6 +1985,7 @@ 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 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence @@ -2056,6 +2065,8 @@ 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.'} @@ -2259,6 +2270,14 @@ 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} 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/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/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/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/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/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/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 2a2520fd..0857b29e 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- `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) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 2afccd6b..a4d80dc1 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- 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)