mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-09-29 18:44:31 +02:00
Merge commit 'd3160a2' into feat/369-missing-audit-controls
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
.\WELA.ps1 configure -Profile asd-native-2021-10 -IncludeOptional -PlanPath result.json
|
||||
```
|
||||
|
||||
Supply both `-Role` and `-Build`, or omit both for Windows host detection. Roles are `Client`, `MemberServer`, `DomainController`, and `ADCS` (CA on a member server). Combined DC/CA deployments are not a separate profile: detection identifies them as DCs; review CA requirements separately. Build means the base build, for example 20348 (Server 2022), 26100 (Windows 11 24H2 / Server 2025), or 26200 (Windows 11 25H2). Live application checks the actual Windows host; a supplied role/build cannot authorize applying a mismatched plan. Versioned SCT profiles reject other base builds. Unsupported profiles/hosts and unreadable policies fail before writes.
|
||||
Supply both `-Role` and `-Build`, or omit both for Windows host detection. Roles are `Client`, `MemberServer`, `DomainController`, and `ADCS` (CA on a member server). Combined DC/CA deployments are not supported by these role profiles: host detection refuses them before configuration writes, rather than silently omitting CA auditing. A failure to read the CA installation state is also an error, not evidence of a member server without CA. Build means the base build, for example 20348 (Server 2022), 26100 (Windows 11 24H2 / Server 2025), or 26200 (Windows 11 25H2). Live application checks the actual Windows host; a supplied role/build cannot authorize applying a mismatched plan. Versioned SCT profiles reject other base builds. Unsupported profiles/hosts and unreadable policies fail before writes.
|
||||
|
||||
The WELA and documentary guide profiles currently cover the reviewed Windows 11/Server 2022/Server 2025 range. Older/future operating systems require a reviewed applicability update. `-Baseline` retains the legacy display interface for non-Yamato guides; use `-Profile` to select the versioned shared definitions. Do not combine `-Baseline` and `-Profile`.
|
||||
|
||||
@@ -59,7 +59,7 @@ Mask bits are Success `1`, Failure `2`, both `3`, neither `0`.
|
||||
|
||||
For example Detailed File Share is exact S+F in WELA, minimum Failure in the reviewed CIS profiles, and Not Configured in ASD. These are deliberate differences, not a universal “enable everything” preset. Omitted values and unknown effective state are different: unknown state blocks application instead of becoming mask zero.
|
||||
|
||||
Effective policy is read through the Windows [AuditQuerySystemPolicy API](https://learn.microsoft.com/en-us/windows/win32/api/ntsecapi/nf-ntsecapi-auditquerysystempolicy); localized `auditpol /get /r` text is not parsed. Apply reads effective state again, checks native command errors, then verifies each changed mask. A command that exits successfully but does not change effective policy is reported as failed. Group Policy can reapply after a successful verification: these are local effective-policy changes, not GPO authoring. Exported plan/current state and apply results include the profile version, schema SHA-256, source provenance, before/target/effective masks and failure details. This feature does not validate event generation, SACL correctness, ingestion, or Sigma field compatibility.
|
||||
Effective policy is read through the Windows [AuditQuerySystemPolicy API](https://learn.microsoft.com/en-us/windows/win32/api/ntsecapi/nf-ntsecapi-auditquerysystempolicy); localized `auditpol /get /r` text is not parsed. Apply reads effective state immediately before each control, checks native command errors, then verifies each changed policy. Minimum policies only enable required native flags, never disable additional flags, and accept any effective state containing the required bits. Exact policies require the exact mask. A command that exits successfully but does not change effective policy is reported as failed. Group Policy can reapply after a successful verification: these are local effective-policy changes, not GPO authoring. Exported plan/current state and apply results include the profile version, schema SHA-256, source provenance, before/target/effective masks and failure details. This feature does not validate event generation, SACL correctness, ingestion, or Sigma field compatibility.
|
||||
|
||||
## Extending the schema and testing
|
||||
|
||||
|
||||
+67
-21
@@ -177,26 +177,58 @@ namespace Wela.AuditProfiles {
|
||||
return $current
|
||||
}
|
||||
|
||||
function Get-WelaAuditSetArguments {
|
||||
param(
|
||||
[ValidatePattern('^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$')][string]$Guid,
|
||||
[ValidateRange(0, 3)][int]$Mask,
|
||||
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact'
|
||||
)
|
||||
$arguments = @('/set', "/subcategory:{$Guid}")
|
||||
if ($Mode -eq 'minimum') {
|
||||
# Only enable required bits; never clear another actor's newly enabled bit.
|
||||
if ($Mask -band 1) { $arguments += '/success:enable' }
|
||||
if ($Mask -band 2) { $arguments += '/failure:enable' }
|
||||
} else {
|
||||
$arguments += if ($Mask -band 1) { '/success:enable' } else { '/success:disable' }
|
||||
$arguments += if ($Mask -band 2) { '/failure:enable' } else { '/failure:disable' }
|
||||
}
|
||||
return $arguments
|
||||
}
|
||||
|
||||
function Set-WelaEffectiveAuditPolicy {
|
||||
param([ValidatePattern('^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$')][string]$Guid, [ValidateRange(0, 3)][int]$Mask)
|
||||
$success = if ($Mask -band 1) { 'enable' } else { 'disable' }
|
||||
$failure = if ($Mask -band 2) { 'enable' } else { 'disable' }
|
||||
$output = & auditpol.exe /set "/subcategory:{$Guid}" "/success:$success" "/failure:$failure" 2>&1
|
||||
param(
|
||||
[ValidatePattern('^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$')][string]$Guid,
|
||||
[ValidateRange(0, 3)][int]$Mask,
|
||||
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact'
|
||||
)
|
||||
if ($Mode -eq 'minimum' -and $Mask -eq 0) { return }
|
||||
$arguments = @(Get-WelaAuditSetArguments -Guid $Guid -Mask $Mask -Mode $Mode)
|
||||
$output = & auditpol.exe @arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "auditpol /set failed ($LASTEXITCODE): $($output -join ' ')" }
|
||||
}
|
||||
|
||||
function Get-WelaHostContext {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
||||
$system = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
||||
param(
|
||||
[scriptblock]$ReadOperatingSystem = { Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop },
|
||||
[scriptblock]$ReadComputerSystem = { Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop },
|
||||
[scriptblock]$ReadCertificateAuthority = { Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' -ErrorAction Stop }
|
||||
)
|
||||
$os = & $ReadOperatingSystem
|
||||
$system = & $ReadComputerSystem
|
||||
if ([int]$os.ProductType -notin @(1, 2, 3) -or [int]$system.DomainRole -notin @(0, 1, 2, 3, 4, 5) -or [int]$os.BuildNumber -le 0) { throw 'Cannot determine a valid Windows role/build.' }
|
||||
if (([int]$os.ProductType -eq 1 -and [int]$system.DomainRole -notin @(0, 1)) -or
|
||||
([int]$os.ProductType -eq 2 -and [int]$system.DomainRole -notin @(4, 5)) -or
|
||||
([int]$os.ProductType -eq 3 -and [int]$system.DomainRole -notin @(2, 3))) { throw 'Windows ProductType and DomainRole disagree.' }
|
||||
$hasCA = $false
|
||||
if ([int]$os.ProductType -ne 1) {
|
||||
$hasCA = & $ReadCertificateAuthority
|
||||
if ($hasCA -isnot [bool]) { throw 'Cannot determine whether Certificate Services is installed.' }
|
||||
if ($hasCA -and [int]$system.DomainRole -in @(4, 5)) { throw 'Combined domain-controller/CA hosts are unsupported by the current role profiles. No configuration should be applied.' }
|
||||
}
|
||||
$role = if ([int]$os.ProductType -eq 1) { 'Client' }
|
||||
elseif ([int]$system.DomainRole -in @(4, 5)) { 'DomainController' }
|
||||
elseif (Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration') { 'ADCS' }
|
||||
elseif ($hasCA) { 'ADCS' }
|
||||
else { 'MemberServer' }
|
||||
[pscustomobject]@{ Role = $role; Build = [int]$os.BuildNumber }
|
||||
}
|
||||
@@ -218,7 +250,7 @@ function Invoke-WelaAuditProfilePlan {
|
||||
param(
|
||||
[Parameter(Mandatory)]$Plan,
|
||||
[scriptblock]$ReadPolicy = { Get-WelaEffectiveAuditPolicy },
|
||||
[scriptblock]$WritePolicy = { param($Guid, $Mask) Set-WelaEffectiveAuditPolicy -Guid $Guid -Mask $Mask },
|
||||
[scriptblock]$WritePolicy,
|
||||
[scriptblock]$ReadContext = { Get-WelaHostContext }
|
||||
)
|
||||
$hostContext = & $ReadContext
|
||||
@@ -226,20 +258,34 @@ function Invoke-WelaAuditProfilePlan {
|
||||
Assert-WelaAuditProfileTarget -Plan $Plan -Context $hostContext -Current $before
|
||||
$selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) })
|
||||
$results = foreach ($policy in $selected) {
|
||||
$initial = $before[$policy.guid]; $effective = $initial; $errorText = $null
|
||||
$target = if ($policy.mode -eq 'minimum') { [int]$initial -bor [int]$policy.requiredMask } else { [int]$policy.requiredMask }
|
||||
$status = 'No change'
|
||||
if ($initial -ne $target) {
|
||||
if ($PSCmdlet.ShouldProcess($policy.id, "Set audit policy to $(Format-WelaAuditMask $target)")) {
|
||||
try {
|
||||
& $WritePolicy $policy.guid $target | Out-Null
|
||||
$initial = $null; $effective = $null; $target = $null; $errorText = $null; $status = 'No change'
|
||||
try {
|
||||
# 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 ($PSCmdlet.ShouldProcess($policy.id, "Set audit policy to $(Format-WelaAuditMask $target)")) {
|
||||
$writeMode = if ($isMinimum) { 'minimum' } else { 'exact' }
|
||||
if ($WritePolicy) {
|
||||
# Existing two-argument test providers retain their merged-mask contract.
|
||||
# A third mode argument lets providers preserve concurrent additional flags.
|
||||
& $WritePolicy $policy.guid $target $writeMode | Out-Null
|
||||
} else {
|
||||
$writeMask = if ($isMinimum) { $policy.requiredMask } else { $target }
|
||||
Set-WelaEffectiveAuditPolicy -Guid $policy.guid -Mask $writeMask -Mode $writeMode
|
||||
}
|
||||
$verified = & $ReadPolicy
|
||||
$effective = if ($verified.ContainsKey($policy.guid)) { $verified[$policy.guid] } else { $null }
|
||||
if ($effective -ne $target) { throw 'Effective policy does not match the requested mask (GPO or command failure).' }
|
||||
$effective = if ($verified -is [hashtable] -and $verified.ContainsKey($policy.guid)) { $verified[$policy.guid] } else { $null }
|
||||
if ($null -eq $effective -or $effective -notin @(0, 1, 2, 3)) { throw 'Effective policy is unknown after application.' }
|
||||
$matches = if ($isMinimum) { ([int]$effective -band [int]$policy.requiredMask) -eq [int]$policy.requiredMask } else { $effective -eq $target }
|
||||
if (-not $matches) { throw 'Effective policy does not meet the requested audit requirement (GPO or command failure).' }
|
||||
$status = 'Applied'
|
||||
} catch { $status = 'Failed'; $errorText = $_.Exception.Message; $effective = $null }
|
||||
} else { $status = 'Skipped' }
|
||||
}
|
||||
} else { $status = 'Skipped' }
|
||||
}
|
||||
} catch { $status = 'Failed'; $errorText = $_.Exception.Message; $effective = $null }
|
||||
[pscustomobject]@{
|
||||
id = $policy.id; guid = $policy.guid; mode = $policy.mode
|
||||
beforeMask = $initial; targetMask = $target; effectiveMask = $effective; status = $status; error = $errorText
|
||||
|
||||
@@ -70,6 +70,51 @@ Assert ($again.success -and $script:Writes.Count -eq $count) 'applying twice is
|
||||
$script:State = $zero.Clone(); $script:State[$shareGuid] = 1
|
||||
$minimum = Invoke-WelaAuditProfilePlan -Plan $cis -ReadPolicy $reader -WritePolicy $writer -ReadContext $context -Confirm:$false
|
||||
Assert ($minimum.success -and $script:State[$shareGuid] -eq 3) 'fresh effective flags are preserved in minimum apply'
|
||||
# Minimum readback permits additional flags enabled by Windows/GPO after the write.
|
||||
$single = Get-WelaAuditProfilePlan -Profile cis-win11-v4-l1 -Role Client -Build 26100 -Current $zero
|
||||
$single.policies = @($single.policies | Where-Object { $_.id -eq 'Detailed File Share' })
|
||||
$script:State = $zero.Clone()
|
||||
$extra = Invoke-WelaAuditProfilePlan -Plan $single -ReadPolicy $reader -WritePolicy {
|
||||
param($Guid, $Mask, $Mode)
|
||||
Assert ($Mode -eq 'minimum') 'injected writer receives policy semantics'
|
||||
$script:State[$Guid] = 3
|
||||
} -ReadContext $context -Confirm:$false
|
||||
Assert ($extra.success -and $extra.results[0].targetMask -eq 2 -and $extra.results[0].effectiveMask -eq 3) 'minimum Failure accepts post-write Success+Failure'
|
||||
# A flag introduced after whole-plan preflight is included in the immediate control read.
|
||||
$script:State = $zero.Clone(); $script:Reads = 0; $script:WrittenMask = $null
|
||||
$race = Invoke-WelaAuditProfilePlan -Plan $single -ReadPolicy {
|
||||
$script:Reads++
|
||||
if ($script:Reads -eq 2) { $script:State[$shareGuid] = 1 }
|
||||
$script:State.Clone()
|
||||
} -WritePolicy {
|
||||
param($Guid, $Mask, $Mode)
|
||||
$script:WrittenMask = $Mask
|
||||
$script:State[$Guid] = $Mask
|
||||
} -ReadContext $context -Confirm:$false
|
||||
Assert ($race.success -and $script:WrittenMask -eq 3 -and $race.results[0].beforeMask -eq 1) 'fresh per-control read preserves a flag introduced after preflight'
|
||||
$script:Reads = 0; $script:WrittenMask = $null
|
||||
$unknownRace = Invoke-WelaAuditProfilePlan -Plan $single -ReadPolicy {
|
||||
$script:Reads++
|
||||
if ($script:Reads -eq 1) { $zero.Clone() } else { @{} }
|
||||
} -WritePolicy { $script:WrittenMask = 1 } -ReadContext $context -Confirm:$false
|
||||
Assert (-not $unknownRace.success -and $null -eq $script:WrittenMask -and $unknownRace.results[0].sourceIds.Count -gt 0) 'state becoming unknown blocks that write and retains evidence'
|
||||
# Verify the real native command contract: minimum never supplies an unrequired disable.
|
||||
$minimumArgs = @(& (Get-Module AuditProfiles) { param($Guid) Get-WelaAuditSetArguments -Guid $Guid -Mask 2 -Mode minimum } $shareGuid)
|
||||
Assert ($minimumArgs -contains '/failure:enable' -and @($minimumArgs | Where-Object { $_ -like '/success:*' -or $_ -like '*:disable' }).Count -eq 0) 'minimum Failure writes only failure-enable, preserving concurrent Success'
|
||||
$exactArgs = @(& (Get-Module AuditProfiles) { param($Guid) Get-WelaAuditSetArguments -Guid $Guid -Mask 1 -Mode exact } $shareGuid)
|
||||
Assert ($exactArgs -contains '/success:enable' -and $exactArgs -contains '/failure:disable') 'exact Success deliberately clears Failure'
|
||||
# Role classification must not guess when CA presence is unreadable, or omit CA policy on a DC.
|
||||
$serverOS = { [pscustomobject]@{ ProductType = 3; BuildNumber = 26100 } }
|
||||
$dcOS = { [pscustomobject]@{ ProductType = 2; BuildNumber = 26100 } }
|
||||
$memberSystem = { [pscustomobject]@{ DomainRole = 3 } }
|
||||
$dcSystem = { [pscustomobject]@{ DomainRole = 5 } }
|
||||
Assert-Throws { Get-WelaHostContext -ReadOperatingSystem $serverOS -ReadComputerSystem $memberSystem -ReadCertificateAuthority { throw 'CA registry access denied' } } 'access denied'
|
||||
Assert-Throws { Get-WelaHostContext -ReadOperatingSystem $serverOS -ReadComputerSystem $memberSystem -ReadCertificateAuthority { $null } } 'Cannot determine'
|
||||
Assert-Throws { Get-WelaHostContext -ReadOperatingSystem $dcOS -ReadComputerSystem $dcSystem -ReadCertificateAuthority { $true } } 'Combined domain-controller/CA'
|
||||
$ca = Get-WelaHostContext -ReadOperatingSystem $serverOS -ReadComputerSystem $memberSystem -ReadCertificateAuthority { $true }
|
||||
Assert ($ca.Role -eq 'ADCS') 'member-server CA remains supported'
|
||||
$dc = Get-WelaHostContext -ReadOperatingSystem $dcOS -ReadComputerSystem $dcSystem -ReadCertificateAuthority { $false }
|
||||
Assert ($dc.Role -eq 'DomainController') 'DC without CA remains supported'
|
||||
$script:State = $zero.Clone()
|
||||
$failed = Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy $reader -WritePolicy { throw 'command failed' } -ReadContext $context -Confirm:$false
|
||||
Assert (-not $failed.success -and @($failed.results | Where-Object { $_.status -eq 'Failed' }).Count -gt 0) 'native failure is machine-readable'
|
||||
|
||||
Reference in New Issue
Block a user