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 8e912d6c..aff5b0cf 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 -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/ipsec-prerequisites.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a6ab89ad..d0091ce8 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 強化プロファイルのオプション 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 c13c6111..662b394c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- 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 fb027456..35e315ae 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -524,7 +524,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.' } @@ -538,6 +538,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 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/modules/AuditProfiles.psm1 b/modules/AuditProfiles.psm1 index 65b21903..d99efc4d 100644 --- a/modules/AuditProfiles.psm1 +++ b/modules/AuditProfiles.psm1 @@ -1,6 +1,7 @@ # Requires Windows PowerShell 5.1 or PowerShell 7. No Windows dependency for schema/planning. Set-StrictMode -Version 2.0 . (Join-Path $PSScriptRoot '../scripts/CustomAuditProfiles.ps1') +. (Join-Path $PSScriptRoot '../scripts/IpsecPrerequisites.ps1') function Get-WelaProperty { param($Object, [string]$Name, $Default = $null) @@ -68,7 +69,8 @@ function Get-WelaAuditProfilePlan { [Parameter(Mandatory)][string]$Profile, [Parameter(Mandatory)][ValidateSet('Client', 'MemberServer', 'DomainController', 'ADCS')][string]$Role, [Parameter(Mandatory)][ValidateRange(1, 999999)][int]$Build, - [hashtable]$Current = @{}, [switch]$IncludeOptional, + [hashtable]$Current = @{}, [switch]$IncludeOptional, [switch]$ObserveIpsec, + [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite }, [string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json'), [switch]$CustomFile ) @@ -85,6 +87,10 @@ function Get-WelaAuditProfilePlan { foreach ($property in $selected.controls.PSObject.Properties) { $controls[$property.Name] = $property.Value } $override = Get-WelaProperty $selected.roleOverrides $Role if ($override) { foreach ($property in $override.PSObject.Properties) { $controls[$property.Name] = $property.Value } } + $ipsec = $null + if (-not $CustomFile -and $selected.id -ceq 'microsoft-stronger-reviewed-2026-09') { + $ipsec = if ($ObserveIpsec) { & $ReadIpsec } else { Get-WelaIpsecPrerequisite -Offline } + } $rows = foreach ($policy in $data.catalog) { $control = $controls[$policy.id] $mode = if ($control) { $control.mode } else { 'unchanged' } @@ -102,7 +108,17 @@ function Get-WelaAuditProfilePlan { $compliance = if ($action -eq 'No change') { 'Compliant' } else { 'Drift' } } } + $conditional = $null + if ($ipsec -and $policy.id -eq 'IPsec Main Mode' -and $mode -eq 'optional') { + $conditional = $ipsec + if ($IncludeOptional -and $ipsec.Status -ne 'Applicable') { + $desired = $null + $action = if ($ipsec.Status -eq 'NotObservedWithinScope') { 'Preserve (IPsec not observed in scope)' } else { 'Unknown IPsec prerequisite' } + $compliance = 'Not assessed' + } + } [pscustomobject][ordered]@{ + conditionalPrerequisite = $conditional id = $policy.id; guid = $policy.guid; category = $policy.category; mode = $mode requiredMask = $mask; currentMask = $currentMask; targetMask = $desired recommendation = if ($mode -in @('exact', 'minimum', 'optional')) { "$(Format-WelaAuditMask $mask) [$mode]" } else { $mode } @@ -268,7 +284,8 @@ function Invoke-WelaAuditProfilePlan { [Parameter(Mandatory)]$Plan, [scriptblock]$ReadPolicy = { Get-WelaEffectiveAuditPolicy }, [scriptblock]$WritePolicy, - [scriptblock]$ReadContext = { Get-WelaHostContext } + [scriptblock]$ReadContext = { Get-WelaHostContext }, + [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite } ) if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource } $hostContext = & $ReadContext @@ -277,21 +294,28 @@ function Invoke-WelaAuditProfilePlan { $selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) }) $results = foreach ($policy in $selected) { $initial = $null; $effective = $null; $target = $null; $errorText = $null; $status = 'No change' + $conditional = Test-WelaIpsecConditionalPolicy $Plan $policy; $observations = @(); $skipConditional = $false try { if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource } + if ($conditional) { + $evidence = & $ReadIpsec; $observations += $evidence + if ($evidence.Status -eq 'NotObservedWithinScope') { $status = 'Skipped'; $skipConditional = $true; $errorText = 'IPsec prerequisite not observed within the documented native scope; policy preserved.' } + else { Assert-WelaIpsecPrerequisite $evidence } + } # Whole-plan preflight is not a current-state cache: re-read immediately before each control. $fresh = & $ReadPolicy if ($fresh -isnot [hashtable] -or -not $fresh.ContainsKey($policy.guid) -or $null -eq $fresh[$policy.guid] -or $fresh[$policy.guid] -notin @(0, 1, 2, 3)) { throw 'Current audit policy became unknown before application.' } $initial = $fresh[$policy.guid]; $effective = $initial $isMinimum = $policy.mode -eq 'minimum' $target = if ($isMinimum) { [int]$initial -bor [int]$policy.requiredMask } else { [int]$policy.requiredMask } - if ($initial -ne $target) { + if (-not $skipConditional -and $initial -ne $target) { if ($PSCmdlet.ShouldProcess($policy.id, "Set audit policy to $(Format-WelaAuditMask $target)")) { if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource $freshContext = & $ReadContext if ($freshContext.Role -ne $Plan.role -or $freshContext.Build -ne $Plan.build) { throw 'Custom profile target changed before application.' } } + if ($conditional) { $evidence = & $ReadIpsec; $observations += $evidence; Assert-WelaIpsecPrerequisite $evidence } $writeMode = if ($isMinimum) { 'minimum' } else { 'exact' } if ($WritePolicy) { # Existing two-argument test providers retain their merged-mask contract. @@ -314,6 +338,7 @@ function Invoke-WelaAuditProfilePlan { [pscustomobject]@{ id = $policy.id; guid = $policy.guid; mode = $policy.mode beforeMask = $initial; targetMask = $target; effectiveMask = $effective; status = $status; error = $errorText + prerequisiteObservations = $observations prerequisites = $policy.prerequisites; evidence = $policy.evidence; sourceIds = @($policy.sourceIds) } } @@ -325,4 +350,4 @@ function Invoke-WelaAuditProfilePlan { } } -Export-ModuleMember -Function Import-WelaAuditProfiles, Import-WelaCustomAuditProfiles, Assert-WelaCustomProfileSource, Get-WelaCustomReportPath, Write-WelaCustomProfileReport, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan +Export-ModuleMember -Function Get-WelaIpsecPrerequisite, Assert-WelaIpsecPrerequisite, Test-WelaIpsecConditionalPolicy, Import-WelaAuditProfiles, Import-WelaCustomAuditProfiles, Assert-WelaCustomProfileSource, Get-WelaCustomReportPath, Write-WelaCustomProfileReport, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 3b93b4e6..6e5297fa 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -284,10 +284,17 @@ function Get-WelaAuditPolicyMask { function Set-WelaAuditPolicyControl { param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3, - [ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence) + [ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence, + $IpsecObservations, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite }) $guid = $Policy.GUID - $state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence } - $read = { param($state) Get-WelaAuditPolicyMask -Guid $state.Guid } + $state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence; IpsecObservations=$IpsecObservations; ReadIpsec=$ReadIpsec } + $read = { param($state) + if ($null -ne $state.IpsecObservations) { + $evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence) + Assert-WelaIpsecPrerequisite $evidence + } + Get-WelaAuditPolicyMask -Guid $state.Guid + } $test = { param($value, $state) if ($state.Mode -eq 'minimum') { return ($value -band $state.Mask) -eq $state.Mask } @@ -311,6 +318,11 @@ function Set-WelaAuditPolicyControl { $failure = if ($state.Mask -band 2) { 'enable' } else { 'disable' } $arguments += "/success:$success", "/failure:$failure" } + if ($null -ne $state.IpsecObservations) { + # This check runs after the operator prompt and durable recovery journal. + $evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence) + Assert-WelaIpsecPrerequisite $evidence + } Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments $arguments } Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy ` @@ -318,7 +330,7 @@ function Set-WelaAuditPolicyControl { } function Set-WelaProfileAuditControls { - param($Context, $Plan) + param($Context, $Plan, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite }) if ($Plan.PSObject.Properties['CustomProfileSource']) { $Context | Add-Member NoteProperty CustomProfileGuard ([pscustomobject]@{Source=$Plan.CustomProfileSource;Role=$Plan.role;Build=$Plan.build}) -Force Assert-WelaConfigurationProfileGuard $Context @@ -334,9 +346,26 @@ function Set-WelaProfileAuditControls { $Context.Results.Add([pscustomobject]@{ Id = "AuditPolicy/$($policy.id)"; Kind = 'AuditPolicy'; Target = @{ Guid = $policy.guid }; Desired = $policy.requiredMask; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'Audit precedence was not verified; dependent policy was not changed.' }) continue } - $mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' } - Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence + $conditional = Test-WelaIpsecConditionalPolicy $Plan $policy + $observations = $null; $blocked = $false + if ($conditional) { + $observations = New-Object 'System.Collections.Generic.List[object]' + try { + $evidence = & $ReadIpsec; $observations.Add($evidence) + $blocked = $evidence.Status -ne 'Applicable' + $status = if ($evidence.Status -eq 'NotObservedWithinScope') { 'Skipped' } else { 'Failed' } + $diagnostic = "IPsec prerequisite $($evidence.Status); policy preserved. $($evidence.Diagnostic)" + } catch { $blocked = $true; $status = 'Failed'; $diagnostic = $_.ToString() } + if ($blocked) { + $Context.Results.Add([pscustomobject]@{Id="AuditPolicy/$($policy.id)";Kind='AuditPolicy';Target=@{Guid=$policy.guid};Desired=@{Mask=$policy.requiredMask;Mode='exact'};Before=$null;After=$null;Status=$status;Diagnostic=$diagnostic}) + } + } + if (-not $blocked) { + $mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' } + Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence -IpsecObservations $observations -ReadIpsec $ReadIpsec + } $row = $Context.Results[$Context.Results.Count - 1] + if ($conditional) { $row | Add-Member NoteProperty PrerequisiteObservations $observations } $row | Add-Member NoteProperty Profile $Plan.profile $row | Add-Member NoteProperty Version $Plan.version $row | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256 diff --git a/scripts/IpsecPrerequisites.ps1 b/scripts/IpsecPrerequisites.ps1 new file mode 100644 index 00000000..ef0bc82b --- /dev/null +++ b/scripts/IpsecPrerequisites.ps1 @@ -0,0 +1,73 @@ +# Read-only local NetSecurity evidence. No policy, service or traffic changes. +function Test-WelaIpsecConditionalPolicy { + param($Plan, $Policy) + return (-not $Plan.PSObject.Properties['CustomProfileSource'] -and + $Plan.profile -ceq 'microsoft-stronger-reviewed-2026-09' -and + $Policy.guid -ieq '0CCE9218-69AE-11D9-BED3-505054503030' -and $Policy.mode -eq 'optional') +} + +function Get-WelaIpsecPrerequisite { + [CmdletBinding()] + param([switch]$Offline, + [scriptblock]$ReadRules = { NetSecurity\Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop }, + [scriptblock]$ReadAssociations = { NetSecurity\Get-NetIPsecMainModeSA -ErrorAction Stop }) + $started = [DateTime]::UtcNow.ToString('o') + $rules = @(); $associations = @(); $reads = @(); $diagnostics = @() + if ($Offline) { $diagnostics += 'Offline scenario; this host was not queried.' } + else { + foreach ($source in @('ActiveStoreRules', 'MainModeAssociations')) { + $status = 'Complete'; $errorText = ''; $items = @() + try { + $reader = if ($source -eq 'ActiveStoreRules') { $ReadRules } else { $ReadAssociations } + # Keep at most 4096 observations per native source. A cap is not an empty/successful inventory. + $items = @(& $reader | Select-Object -First 4097) + if ($items.Count -gt 4096) { throw 'Observation cap exceeded (4096 records).' } + $seen = @{} + foreach ($item in $items) { + if ($source -eq 'ActiveStoreRules') { + foreach ($property in @('Name', 'Enabled', 'InboundSecurity', 'OutboundSecurity', 'PrimaryStatus')) { + if ($null -eq $item -or -not $item.PSObject.Properties[$property] -or $null -eq $item.$property) { throw "Missing native rule property: $property." } + } + $name = [string]$item.Name + $enabled = [string]$item.Enabled; $inbound = [string]$item.InboundSecurity; $outbound = [string]$item.OutboundSecurity; $health = [string]$item.PrimaryStatus + if (-not $name -or $name.Length -gt 1024 -or $seen.ContainsKey($name) -or $enabled -cnotin @('True','False') -or + $inbound -cnotin @('None','Request','Require') -or $outbound -cnotin @('None','Request','Require') -or + $health -cnotin @('OK','Inactive','Error','Unknown')) { throw "Unrecognized or duplicate native IPsec rule observation: Name='$name', Enabled='$enabled', InboundSecurity='$inbound', OutboundSecurity='$outbound', PrimaryStatus='$health'." } + $seen[$name] = $true + $qualifies = $enabled -ceq 'True' -and ($inbound -cne 'None' -or $outbound -cne 'None') -and $health -ceq 'OK' + $rules += [pscustomobject]@{ Name=$name; Enabled=$enabled; InboundSecurity=$inbound; OutboundSecurity=$outbound; PrimaryStatus=$health; Qualifies=$qualifies } + if ($enabled -ceq 'True' -and ($inbound -cne 'None' -or $outbound -cne 'None') -and $health -cne 'OK') { throw 'Enabled non-exemption rule has uncertain effective health.' } + } else { + foreach ($property in @('Name','LocalEndpoint','RemoteEndpoint')) { + if ($null -eq $item -or -not $item.PSObject.Properties[$property] -or -not [string]$item.$property) { throw "Missing native association property: $property." } + } + $name = [string]$item.Name; $local = [string]$item.LocalEndpoint; $remote = [string]$item.RemoteEndpoint + $address = $null + if ($name.Length -gt 1024 -or $seen.ContainsKey($name) -or -not [Net.IPAddress]::TryParse($local,[ref]$address) -or -not [Net.IPAddress]::TryParse($remote,[ref]$address)) { throw 'Unrecognized or duplicate main-mode association.' } + $seen[$name] = $true + $associations += [pscustomobject]@{ Name=$name; LocalEndpoint=$local; RemoteEndpoint=$remote } + } + } + } catch { $status = 'Unknown'; $errorText = $_.Exception.Message; $diagnostics += "$source`: $errorText" } + $reads += [pscustomobject]@{ Source=$source; Status=$status; ObservedCount=$items.Count; Diagnostic=$errorText } + } + } + $status = if ($Offline -or @($reads | Where-Object Status -ne Complete).Count) { 'Unknown' } + elseif (@($rules | Where-Object Qualifies).Count -or $associations.Count) { 'Applicable' } + else { 'NotObservedWithinScope' } + [pscustomobject][ordered]@{ + SchemaVersion=1; Status=$status; Scope='Local NetSecurity ActiveStore rules and current main-mode SAs' + StartedUtc=$started; CompletedUtc=[DateTime]::UtcNow.ToString('o'); ComputerName=$env:COMPUTERNAME + Basis=$(if ($status -eq 'Applicable') { 'Enabled healthy non-exemption effective rule or current main-mode SA observed.' } else { 'No complete positive prerequisite evidence.' }) + Reads=$reads; Rules=$rules; MainModeAssociations=$associations; Diagnostic=($diagnostics -join ' ') + Limitations='Point-in-time local scope. Configured rules do not prove matching traffic, successful negotiation or audit events. Absence does not exclude legacy IPsec, VPN or other providers. No event-volume, failure-outcome or Sigma credit.' + } +} + +function Assert-WelaIpsecPrerequisite { + param($Evidence) + if ($null -eq $Evidence -or $Evidence.Status -cne 'Applicable') { + $status = if ($Evidence) { $Evidence.Status } else { 'Unknown' } + throw "IPsec Main Mode prerequisite is $status; this conditional audit setting was not changed. $($Evidence.Diagnostic)" + } +} diff --git a/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/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 2756f6c3..ac4dcdc0 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 強化プロファイルのオプション 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 91d49131..4c3f0713 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- 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)