diff --git a/.github/workflows/configuration-results.yml b/.github/workflows/configuration-results.yml index cce1ace3..ef3aac70 100644 --- a/.github/workflows/configuration-results.yml +++ b/.github/workflows/configuration-results.yml @@ -29,3 +29,9 @@ jobs: - name: NTLM integration in PowerShell 7 shell: pwsh run: ./tests/IntegrationNtlmConfiguration.Tests.ps1 + - name: Profile integration in Windows PowerShell 5.1 + shell: powershell + run: ./tests/IntegrationProfileConfiguration.Tests.ps1 + - name: Profile integration in PowerShell 7 + shell: pwsh + run: ./tests/IntegrationProfileConfiguration.Tests.ps1 diff --git a/WELA.ps1 b/WELA.ps1 index 09ff0dd6..ecf53e8f 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -327,7 +327,7 @@ function Invoke-WelaProfileCommand { Assert-WelaAuditProfileTarget -Plan $plan -Context $actual -Current $current $configurationContext = New-WelaConfigurationContext -Auto:$script:Auto -DryRun:$script:DryRun -BackupPath $script:BackupPath Set-WelaProfileAuditControls -Context $configurationContext -Plan $plan - $result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $script:ResultsPath -Plan $plan + $result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $script:ResultsPath -Plan $plan -Scope advanced-audit-policy-only $result.Results | Format-Table Id, Before, Desired, After, Status -AutoSize } else { $plan.policies | Format-Table id, mode, currentMask, requiredMask, action -AutoSize @@ -1430,7 +1430,7 @@ function ConfigureAuditSettings { ) Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto -Context $context Set-WelaDomainNtlmAudit -Auto:$Auto -Context $context - if (Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters') { + if ($hostContext.Role -eq 'DomainController') { Set-RegistryConfig -RegPaths @( @{Path = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics'; Name = '15 Field Engineering'; Value = 5} ) -Auto:$Auto -Context $context @@ -1774,16 +1774,17 @@ switch ($Cmd.ToLower()) { if ($Help){ Write-Host "Configure Windows Event Log audit settings based on the YamatoSecurity baseline" Write-Host "" - Write-Host "Usage: ./WELA.ps1 configure [-Auto] [-DryRun] [-BackupPath ] [-ResultsPath ] [-OutgoingNtlmMode ]" + Write-Host "Usage: ./WELA.ps1 configure [-Profile ] [-Auto] [-DryRun] [-BackupPath ] [-ResultsPath ] [-OutgoingNtlmMode ]" Write-Host "" Write-Host "Options:" + Write-Host " -Profile Configure advanced audit policy only from a versioned profile; list IDs with profiles" Write-Host " -Auto Automatically configure without prompts" Write-Host " -OutgoingNtlmMode PreserveOrAudit (default): audit, preserving existing deny; Audit: explicitly replace deny; Deny: opt into enforcement" Write-Host " -DryRun Read live state and report proposed changes without writing Windows settings" Write-Host " -BackupPath New directory for the pre-change recovery journal (unique default beside WELA)" Write-Host " -ResultsPath Save structured per-control outcomes as JSON" Write-Host "" - Write-Host "Note: only the YamatoSecurity baseline is currently supported for 'configure'." + Write-Host "Without -Profile, configure applies the YamatoSecurity native logging settings. -Profile applies advanced audit policy only. -DryRun and recovery/results options work with both." Write-Host "" return } diff --git a/docs/configuration-results.md b/docs/configuration-results.md index c2c37646..986acf85 100644 --- a/docs/configuration-results.md +++ b/docs/configuration-results.md @@ -113,3 +113,35 @@ Unknown domain role, unreadable policy and failed writes produce `Failed` outcom and a nonzero overall result while allowing other controls to be assessed. `tests/IntegrationNtlmConfiguration.Tests.ps1` exercises this composed behavior using mocked registry/CIM calls and temporary journals only. + +## Versioned profile integration + +`configure -Profile ` uses the same dry-run, recovery-journal and verification +runner as the broader default `configure` command. Host role/build and all required +effective audit settings are validated before creating a journal or changing any +Windows setting. The Windows-defaults profile remains read-only. + +```powershell +.\WELA.ps1 configure -Profile cis-win11-v4-l1 -DryRun -ResultsPath .\cis-plan.json +.\WELA.ps1 configure -Profile microsoft-sct-win11-24h2 -Auto -BackupPath C:\WELA-Recovery\sct-001 -ResultsPath .\sct-results.json +``` + +Exact recommendations set the named mask; minimum recommendations only enable +required flags and accept a compliant superset. They never disable an unrequested +flag, including one added by another writer between observation and application. +Omitted, Not Configured and non-applicable policies are preserved. Opt-in policies +require `-IncludeOptional`. Both result paths retain version, host role/build, +source identifiers and prerequisites such as SACLs; recording an enabled audit +subcategory does not claim its prerequisite was installed. + +The result `Scope` is `native-windows-configuration` for default configure and +`advanced-audit-policy-only` for `configure -Profile`. `ProfileScope` describes the +advanced-policy subset within either result. `-PlanPath` remains available for +profile JSON output; `-ResultsPath` saves the verified configuration report. + +This composed change depends on the outgoing/domain NTLM corrections, versioned +audit profiles and six additional native audit controls. It preserves their +selection behavior while adding shared execution and recovery reporting. +`tests/IntegrationProfileConfiguration.Tests.ps1` tests the composed command +paths without touching Windows policy, including exact/minimum behavior, concurrent +flags, unknown-state preflight, reference-only defaults, metadata and dry runs. diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 5d0d7410..3f0d5632 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -90,7 +90,9 @@ function Invoke-WelaConfigurationControl { } function Complete-WelaConfiguration { - param($Context, [string]$ResultsPath, $Plan) + param($Context, [string]$ResultsPath, $Plan, + [ValidateSet("native-windows-configuration", "advanced-audit-policy-only")] + [string]$Scope = "native-windows-configuration") # A second read detects a value that was compliant earlier but changed during # this run. It does not establish whether GPO or another writer caused drift. foreach ($check in $Context.Checks) { @@ -109,16 +111,17 @@ function Complete-WelaConfiguration { $skipped = @($Context.Results | Where-Object { $_.Status -eq 'Skipped' }).Count $report = [pscustomobject][ordered]@{ ExitCode = $(if ($failed) { 1 } else { 0 }); DryRun = $Context.DryRun - BackupPath = $Context.BackupPath; Failed = $failed; Skipped = $skipped + BackupPath = $Context.BackupPath; Failed = $failed; Skipped = $skipped; Scope = $Scope Results = @($Context.Results.ToArray()) } if ($Plan) { $report | Add-Member NoteProperty Profile $Plan.profile + $report | Add-Member NoteProperty Version $Plan.version $report | Add-Member NoteProperty Role $Plan.role $report | Add-Member NoteProperty Build $Plan.build $report | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256 $report | Add-Member NoteProperty Provenance $Plan.provenance - $report | Add-Member NoteProperty Scope $Plan.scope + $report | Add-Member NoteProperty ProfileScope $Plan.scope } if ($ResultsPath) { try { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } @@ -243,22 +246,24 @@ function Set-WelaAuditPolicyControl { param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3, [ValidateSet('exact', 'minimum')][string]$Mode = 'exact') $guid = $Policy.GUID - $observed = @{ Mask = $null } - $read = { - $observed.Mask = Get-WelaAuditPolicyMask -Guid $guid - return $observed.Mask - }.GetNewClosure() + $read = { Get-WelaAuditPolicyMask -Guid $guid }.GetNewClosure() $test = { param($value) if ($Mode -eq 'minimum') { return ($value -band $Mask) -eq $Mask } return $value -eq $Mask }.GetNewClosure() $apply = { - # Minimum requirements preserve the flags observed immediately before journaling. - $target = if ($Mode -eq 'minimum') { $observed.Mask -bor $Mask } else { $Mask } - $success = if ($target -band 1) { 'enable' } else { 'disable' } - $failure = if ($target -band 2) { 'enable' } else { 'disable' } - Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments @('/set', "/subcategory:{$guid}", "/success:$success", "/failure:$failure") + $arguments = @('/set', "/subcategory:{$guid}") + if ($Mode -eq 'minimum') { + # Only enable required flags: never disable another writer's added flag. + if ($Mask -band 1) { $arguments += '/success:enable' } + if ($Mask -band 2) { $arguments += '/failure:enable' } + } else { + $success = if ($Mask -band 1) { 'enable' } else { 'disable' } + $failure = if ($Mask -band 2) { 'enable' } else { 'disable' } + $arguments += "/success:$success", "/failure:$failure" + } + Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments $arguments }.GetNewClosure() Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy ` -Target @{ Guid = $guid } -Desired @{ Mask = $Mask; Mode = $Mode } -Read $read -Compliant $test -Apply $apply @@ -273,6 +278,8 @@ function Set-WelaProfileAuditControls { Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode $row = $Context.Results[$Context.Results.Count - 1] $row | Add-Member NoteProperty Profile $Plan.profile + $row | Add-Member NoteProperty Version $Plan.version + $row | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256 $row | Add-Member NoteProperty Role $Plan.role $row | Add-Member NoteProperty Build $Plan.build $row | Add-Member NoteProperty Mode $policy.mode diff --git a/tests/IntegrationProfileConfiguration.Tests.ps1 b/tests/IntegrationProfileConfiguration.Tests.ps1 new file mode 100644 index 00000000..e4188cf2 --- /dev/null +++ b/tests/IntegrationProfileConfiguration.Tests.ps1 @@ -0,0 +1,152 @@ +# Profile command + verified configuration integration. No Windows policy is touched. +$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') +$tokens = $null; $errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +foreach ($name in @('Get-WelaSelectedContext', 'Show-WelaAuditProfilePrerequisites', 'Invoke-WelaProfileCommand', 'ConfigureAuditSettings')) { + $function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) + . ([scriptblock]::Create($function.Extent.Text)) +} +$script:assertions = 0 +$script:cleanup = New-Object 'System.Collections.Generic.List[string]' +$data = Import-WelaAuditProfiles +$zero = @{} +foreach ($policy in $data.catalog) { $zero[$policy.guid] = 0 } +$shareGuid = ($data.catalog | Where-Object id -eq 'Detailed File Share').guid +$processGuid = ($data.catalog | Where-Object id -eq 'Process Creation').guid +$privilegeGuid = ($data.catalog | Where-Object id -eq 'Sensitive Privilege Use').guid +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" } + $script:assertions++ +} +function Assert-Throws([scriptblock]$Action, [string]$Pattern) { + $caught = '' + try { & $Action | Out-Null } catch { $caught = $_.ToString() } + Assert ($caught -match $Pattern) "Expected failure matching '$Pattern', got '$caught'" +} +function Reset-Run([string]$Profile = 'cis-win11-v4-l1', [switch]$DryRun) { + $script:state = $zero.Clone() + $script:writes = @() + $script:failGuid = '' + $script:concurrentGuid = '' + $script:Profile = $Profile + $script:Role = 'Client'; $script:Build = 26100 + $script:hostBuild = 26100 + $script:Baseline = $null; $script:IncludeOptional = $false + $script:Auto = $true; $script:DryRun = [bool]$DryRun + $script:BackupPath = Join-Path ([IO.Path]::GetTempPath()) ('wela-profile-integration-' + [guid]::NewGuid().ToString('N')) + $script:ResultsPath = $script:BackupPath + '-results.json' + $script:PlanPath = $null + $script:cleanup.Add($script:BackupPath) + $script:cleanup.Add($script:ResultsPath) +} +function global:TestWindows { return $true } +function global:TestAdministrator { return $true } +function global:Get-WelaHostContext { [pscustomobject]@{ Role = 'Client'; Build = $script:hostBuild } } +function global:Get-WelaEffectiveAuditPolicy { return $script:state.Clone() } +function global:Get-WelaNativeAuditPolicy { + param($Guid) + if (-not $script:state.ContainsKey($Guid)) { throw "Mock missing policy: $Guid" } + return $script:state[$Guid] +} +function global:Invoke-WelaNative { + param($FilePath, $Arguments) + if ($FilePath -ne 'auditpol.exe' -or $Arguments[0] -ne '/set') { throw 'Unexpected native mutation' } + $guid = ($Arguments | Where-Object { $_ -like '/subcategory:*' }) -replace '^/subcategory:\{([^}]+)\}$', '$1' + $journal = Join-Path $script:BackupPath 'before.jsonl' + if (-not (Test-Path -LiteralPath $journal)) { throw 'Audit mutation occurred before journal' } + $entries = @(Get-Content -LiteralPath $journal | ConvertFrom-Json) + if ($entries[-1].Target.Guid -ne $guid) { throw 'Audit mutation occurred before matching journal entry' } + if ($guid -eq $script:failGuid) { throw 'Mock auditpol write failure' } + # Simulate a concurrent writer enabling Failure after WELA observed the policy. + if ($guid -eq $script:concurrentGuid) { $script:state[$guid] = $script:state[$guid] -bor 2 } + $mask = $script:state[$guid] + foreach ($arg in $Arguments) { + switch ($arg) { + '/success:enable' { $mask = $mask -bor 1 } + '/success:disable' { $mask = $mask -band 2 } + '/failure:enable' { $mask = $mask -bor 2 } + '/failure:disable' { $mask = $mask -band 1 } + } + } + $script:state[$guid] = $mask + $script:writes += [pscustomobject]@{ Guid = $guid; Arguments = $Arguments } + [pscustomobject]@{ ExitCode = 0; Diagnostic = ''; Output = @() } +} +try { + Reset-Run -DryRun + Invoke-WelaProfileCommand configure | Out-Null + $report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + Assert ($script:writes.Count -eq 0 -and $report.DryRun) 'configure -Profile -DryRun makes no audit writes' + Assert ($report.Scope -eq 'advanced-audit-policy-only' -and $report.ProfileScope -eq 'advanced-audit-policy-only') 'Profile-only results declare their narrower scope' + Assert (-not (Test-Path -LiteralPath $script:BackupPath)) 'Profile dry run creates no journal directory' + Assert ($report.Results.Count -gt 0 -and @($report.Results | Where-Object Status -ne Skipped).Count -eq 0) 'Profile dry-run proposals remain explicit skipped results' + + Reset-Run + $script:state[$shareGuid] = 1 + $script:concurrentGuid = $processGuid + Invoke-WelaProfileCommand configure | Out-Null + $report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + Assert ($script:state[$shareGuid] -eq 3) 'Minimum Failure preserves existing Success' + Assert ($script:state[$processGuid] -eq 3) 'Minimum Success preserves concurrently added Failure' + $processWrite = $script:writes | Where-Object Guid -eq $processGuid + Assert ($processWrite.Arguments -contains '/success:enable' -and @($processWrite.Arguments | Where-Object { $_ -like '*:disable' }).Count -eq 0) 'Minimum native command only enables required bits' + Assert ($report.ExitCode -eq 0) 'Minimum supersets pass final compliance verification' + Assert ($report.Profile -eq 'cis-win11-v4-l1' -and $report.Role -eq 'Client' -and $report.Build -eq 26100) 'Final report retains profile role and build' + Assert ($report.Version -and $report.SchemaSha256.Length -eq 64 -and $report.Provenance.Count -gt 0) 'Final report retains version and provenance' + $row = $report.Results | Where-Object { $_.Target.Guid -eq $shareGuid } + Assert ($row.Mode -eq 'minimum' -and $row.Evidence -and $row.SourceIds.Count -gt 0) 'Per-control mode and source evidence survive the context adapter' + $journal = @(Get-Content -LiteralPath (Join-Path $script:BackupPath 'before.jsonl') | ConvertFrom-Json) + Assert (@($journal | Where-Object { $_.Target.Guid -eq $shareGuid -and $_.Before -eq 1 -and $_.Desired.Mask -eq 2 -and $_.Desired.Mode -eq 'minimum' }).Count -eq 1) 'Minimum policy journal preserves before state and requirement semantics' + + Reset-Run 'microsoft-sct-win11-24h2' + $script:state[$privilegeGuid] = 3 + Invoke-WelaProfileCommand configure | Out-Null + Assert ($script:state[$privilegeGuid] -eq 1) 'Exact SCT mask remains exact rather than hardcoded SF' + $privilegeWrite = $script:writes | Where-Object Guid -eq $privilegeGuid + Assert ($privilegeWrite.Arguments -contains '/failure:disable') 'Exact Success deliberately clears unrequested Failure' + + Reset-Run 'wela-2.2.0' + $script:IncludeOptional = $true + Invoke-WelaProfileCommand configure | Out-Null + $report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + $kernel = $report.Results | Where-Object Id -eq 'AuditPolicy/Kernel Object' + Assert ($kernel.Prerequisites -match 'SACL' -and $kernel.Evidence -and $kernel.SourceIds.Count -gt 0) 'Added native controls retain dependency and source evidence after apply' + + Reset-Run + $script:failGuid = $processGuid + Assert-Throws { Invoke-WelaProfileCommand configure } 'advanced audit policies failed' + $report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + Assert ($report.ExitCode -eq 1 -and $report.Failed -eq 1) 'Profile native failure reaches final machine-readable result' + Assert ($script:writes.Count -gt 0) 'Other policy controls continue after one failure' + + Reset-Run 'windows-defaults-reviewed-2026-09' + Assert-Throws { Invoke-WelaProfileCommand configure } 'reference' + Assert ($script:writes.Count -eq 0 -and -not (Test-Path -LiteralPath $script:BackupPath)) 'Reference-only defaults fail before journal creation or mutation' + + Reset-Run + $script:state.Remove($processGuid) + Assert-Throws { Invoke-WelaProfileCommand configure } 'unknown current' + Assert ($script:writes.Count -eq 0 -and -not (Test-Path -LiteralPath $script:BackupPath)) 'Missing required state refuses the entire profile before mutation' + + Reset-Run + $script:hostBuild = 19045 + Assert-Throws { ConfigureAuditSettings -Auto -BackupPath $script:BackupPath } 'does not support' + Assert ($script:writes.Count -eq 0 -and -not (Test-Path -LiteralPath $script:BackupPath)) 'Legacy configure rejects unsupported hosts before any control or journal' + $referencePlan = Get-WelaAuditProfilePlan -Profile wela-2.2.0 -Role Client -Build 26100 -Current $zero + $emptyContext = New-WelaConfigurationContext -DryRun + $broaderReport = Complete-WelaConfiguration -Context $emptyContext -Plan $referencePlan + Assert ($broaderReport.Scope -eq 'native-windows-configuration' -and $broaderReport.ProfileScope -eq 'advanced-audit-policy-only') 'Broad configure scope is not mislabeled as its audit-policy profile scope' + $engine = (Get-Process -Id $PID).Path + $helpOutput = & $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -Profile cis-win11-v4-l1 -Help 2>&1 + Assert ($LASTEXITCODE -eq 0 -and ($helpOutput -join ' ') -match 'Usage:.*-Profile') 'Profile configure help returns usage without entering the Windows mutation path' + Write-Host "PASS: $script:assertions profile configuration integration assertions (mocked; no Windows changes)." +} finally { + foreach ($path in $script:cleanup) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } +}