Integrate profile masks metadata and dry runs with verified execution

This commit is contained in:
Shirofune-Security committed 2026-09-18 22:05:25 +09:00
1 parent 24dcbdd852
commit d96f04dcef
5 files changed
+215 -17

No files matched your search

@@ -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
+5 -4
View File
@@ -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 <new-directory>] [-ResultsPath <json-file>] [-OutgoingNtlmMode <PreserveOrAudit|Audit|Deny>]"
Write-Host "Usage: ./WELA.ps1 configure [-Profile <id>] [-Auto] [-DryRun] [-BackupPath <new-directory>] [-ResultsPath <json-file>] [-OutgoingNtlmMode <PreserveOrAudit|Audit|Deny>]"
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
}
+32
View File
@@ -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 <id>` 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.
+20 -13
View File
@@ -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
@@ -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 }
}
}