Files
WELA/scripts/AuditNotifications.ps1
T
田中ザック Isaac Mathis 35aca8f494 Add OneSettings auditing and Security warning controls (#408)
* Add explicit OneSettings auditing and Security warning controls

* Reference PR 408 in notification changelogs

* Handle expected child CLI failure under Windows PowerShell 5.1

* Block dependent Privacy channel changes when OneSettings policy drifts

* Recheck notification producer prerequisites at channel write boundaries
2026-09-20 13:55:24 +09:00

188 lines
14 KiB
PowerShell

# Explicit native audit policy controls. Read-only unless Configure is selected.
function Get-WelaNotificationHost {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return [pscustomobject]@{Status='NotApplicable';Diagnostic='Windows required.'} }
try {
if (-not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell for the native policy registry view.' }
$os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
if (($os.ProductType -eq 1 -and $computer.DomainRole -notin @(0,1)) -or
($os.ProductType -eq 2 -and $computer.DomainRole -notin @(4,5)) -or
($os.ProductType -eq 3 -and $computer.DomainRole -notin @(2,3)) -or $os.ProductType -notin @(1,2,3)) { throw 'Unknown or conflicting Windows role observations.' }
$build=[int]$os.BuildNumber
$supported=($os.ProductType -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or
($os.ProductType -in @(2,3) -and $build -in @(20348,26100))
[pscustomobject]@{Status=$(if ($supported) {'Supported'} else {'Unknown'});Build=$build;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Caption=[string]$os.Caption;Diagnostic='Reviewed Windows 11 / Server 2022 and 2025 host families; individual controls have additional gates.'}
} catch { [pscustomobject]@{Status='Unknown';Diagnostic=$_.Exception.Message} }
}
function Get-WelaNotificationDefinitions {
@(
[pscustomobject]@{Id='OneSettings';Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection';Name='EnableOneSettingsAuditing';Channel='Microsoft-Windows-Privacy-Auditing/Operational';Source='CIS Windows 11 Enterprise / Windows Server 2022 v4.0.0, 18.10.16.5';DocumentedDefault='Disabled (source reference; not a clean-host observation)'}
[pscustomobject]@{Id='SecurityWarning';Path='HKLM:\SYSTEM\CurrentControlSet\Services\Eventlog\Security';Name='WarningLevel';Channel='Security';Source='CIS Windows 11 Enterprise v4.0.0 18.5.13 / Windows Server 2022 v4.0.0 18.5.12';DocumentedDefault='0 percent (source reference; not a clean-host observation)'}
)
}
function Get-WelaOneSettingsDefinitionEvidence {
$path=Join-Path $env:windir 'PolicyDefinitions/DataCollection.admx'
$settings=New-Object Xml.XmlReaderSettings
$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit; $settings.XmlResolver=$null
$reader=[Xml.XmlReader]::Create($path,$settings)
try {
$doc=New-Object Xml.XmlDocument; $doc.XmlResolver=$null; $doc.Load($reader)
$policies=@($doc.SelectNodes("//*[local-name()='policy']") | Where-Object {
$_.GetAttribute('name') -eq 'EnableOneSettingsAuditing' -and $_.GetAttribute('class') -eq 'Machine' -and
$_.GetAttribute('key') -eq 'Software\Policies\Microsoft\Windows\DataCollection' -and $_.GetAttribute('valueName') -eq 'EnableOneSettingsAuditing'
})
if ($policies.Count -ne 1) { throw 'Exact OneSettings machine policy mapping is absent or ambiguous.' }
$enabled=$policies[0].SelectSingleNode("./*[local-name()='enabledValue']/*[local-name()='decimal']")
if (-not $enabled -or $enabled.GetAttribute('value') -ne '1') { throw 'OneSettings enabled DWORD definition is not 1.' }
[pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash;Mapping='EnableOneSettingsAuditing DWORD 1'}
} finally { $reader.Dispose() }
}
function Get-WelaNotificationSnapshot {
param($Definition)
$hostState=Get-WelaNotificationHost
$result=[pscustomobject]@{Host=$hostState;Status=$hostState.Status;Policy=$null;Channel=$null;DefinitionEvidence=$null;WarningGeneration='Not applicable to this control';Diagnostic=$hostState.Diagnostic}
if ($hostState.Status -ne 'Supported') { return $result }
try {
if ($Definition.Id -eq 'OneSettings') {
# CIS Server 2022 explicitly includes this control. The client CSP alone
# does not establish Server 2025 support; leave that family unverified.
if ($hostState.ProductType -ne 1 -and $hostState.Build -ne 20348) { throw 'OneSettings on this server release lacks reviewed source support; no policy will be created.' }
$result.DefinitionEvidence=Get-WelaOneSettingsDefinitionEvidence
}
$result.Policy=Get-WelaRegistryState -Path $Definition.Path -Name $Definition.Name
$result.Channel=Get-WelaNativeChannel -Name $Definition.Channel
if ($result.Policy.ValueExists -and $result.Policy.Type -ne 'DWord') { throw 'Existing policy value has an unexpected registry type; preserved for manual review.' }
if ($Definition.Id -eq 'OneSettings') {
if ($result.Policy.ValueExists -and $result.Policy.Value -notin @(0,1)) { throw 'Unknown OneSettings DWORD value; preserved for manual review.' }
if (-not (Test-WelaNativeChannelSnapshot $result.Channel)) { throw 'Privacy-Auditing channel metadata is absent or unreadable; event logging prerequisites are not established.' }
$result.Diagnostic='Registry policy and native channel observed; OneSettings event generation and forwarding remain unverified.'
} else {
$result.WarningGeneration=switch ($result.Channel.LogMode) {
'Circular' {'Not expected: overwrite mode suppresses this warning'}
'Retain' {'Conditional: retained log mode; threshold event still requires lab validation'}
'AutoBackup' {'Unknown: automatic archive rollover behavior requires lab validation'}
default {'Unknown: Security log retention mode unreadable'}
}
$result.Diagnostic='Threshold compliance is independent of warning generation, disk space, archive retention and forwarding health.'
}
} catch { $result.Status='Unknown'; $result.Diagnostic=$_.Exception.Message }
return $result
}
function Test-WelaNotificationValueEqual {
param($First,$Second)
return $First.ValueExists -eq $Second.ValueExists -and $First.Type -ceq $Second.Type -and
(ConvertTo-Json $First.Value -Compress) -ceq (ConvertTo-Json $Second.Value -Compress)
}
function Get-WelaNotificationPlan {
param([ValidateSet('OneSettings','SecurityWarning')][string[]]$Control=@('OneSettings','SecurityWarning'),[ValidateRange(1,90)][int]$WarningPercent=90)
foreach ($definition in Get-WelaNotificationDefinitions) {
if ($definition.Id -notin $Control) { continue }
$snapshot=Get-WelaNotificationSnapshot $definition
$desired=if ($definition.Id -eq 'OneSettings') {1} else {$WarningPercent}
# Threshold is an upper bound: preserve an existing earlier warning.
if ($definition.Id -eq 'SecurityWarning' -and $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -eq 'DWord' -and
$snapshot.Policy.Value -ge 1 -and $snapshot.Policy.Value -le $WarningPercent) { $desired=[int]$snapshot.Policy.Value }
$matches=$snapshot.Status -eq 'Supported' -and $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -eq 'DWord' -and $snapshot.Policy.Value -eq $desired
[pscustomobject]@{Definition=$definition;Before=$snapshot;Desired=$desired;Status=$(if ($snapshot.Status -ne 'Supported') {$snapshot.Status} elseif ($matches) {'PolicyMatches'} else {'ChangeRequired'});ThresholdMaximum=$WarningPercent}
}
}
function Set-WelaNotificationControls {
param($Context,[array]$Plan)
foreach ($entry in $Plan) {
$state=@{Entry=$entry;Initial=$true;JournalState=$null}
$read={
param($state)
$current=Get-WelaNotificationSnapshot $state.Entry.Definition
if ($current.Status -ne 'Supported') { throw "Control prerequisites unavailable: $($current.Diagnostic)" }
if ($state.Initial) {
if ((ConvertTo-Json $current.Host -Compress) -cne (ConvertTo-Json $state.Entry.Before.Host -Compress) -or
-not (Test-WelaNotificationValueEqual $current.Policy $state.Entry.Before.Policy)) { throw 'Notification plan is stale; review a fresh plan.' }
$state.JournalState=$current; $state.Initial=$false
}
return $current
}
$test={param($current,$state) $current.Policy.ValueExists -and $current.Policy.Type -eq 'DWord' -and $current.Policy.Value -eq $state.Entry.Desired}
$apply={
param($state)
$fresh=Get-WelaNotificationSnapshot $state.Entry.Definition
if ($fresh.Status -ne 'Supported' -or
(ConvertTo-Json $fresh.Host -Compress) -cne (ConvertTo-Json $state.JournalState.Host -Compress) -or
(ConvertTo-Json $fresh.DefinitionEvidence -Compress) -cne (ConvertTo-Json $state.JournalState.DefinitionEvidence -Compress) -or
-not (Test-WelaNotificationValueEqual $fresh.Policy $state.JournalState.Policy)) { throw 'Notification state changed after journaling; write refused.' }
New-WelaRegistryKey -Path $state.Entry.Definition.Path
$value=Get-WelaRegistryState -Path $state.Entry.Definition.Path -Name $state.Entry.Definition.Name
if (-not (Test-WelaNotificationValueEqual $value $fresh.Policy)) { throw 'Notification registry value changed immediately before writing.' }
Set-ItemProperty -LiteralPath $state.Entry.Definition.Path -Name $state.Entry.Definition.Name -Value $state.Entry.Desired -Type DWord -ErrorAction Stop
}
Invoke-WelaConfigurationControl -Context $Context -Id "AuditNotifications/$($entry.Definition.Id)" -Kind Registry -Target @{Path=$entry.Definition.Path;Name=$entry.Definition.Name} -Desired @{Type='DWord';Value=$entry.Desired} -Read $read -Compliant $test -Apply $apply -CallbackState $state
}
}
function Invoke-WelaNotificationCommand {
param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',
[ValidateSet('OneSettings','SecurityWarning')][string[]]$Control,
[ValidateRange(1,90)][int]$WarningPercent=90,[switch]$EnablePrivacyChannel,
[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath)
if ($Action -eq 'Configure' -and -not $Control) { throw 'Configure requires an explicit NotificationControl selection.' }
if (-not $Control) { $Control=@('OneSettings','SecurityWarning') }
if ($EnablePrivacyChannel -and 'OneSettings' -notin $Control) { throw 'EnablePrivacyChannel requires the OneSettings control.' }
if ($DryRun -and $Action -ne 'Configure') { throw 'DryRun requires Configure.' }
$plan=@(Get-WelaNotificationPlan -Control $Control -WarningPercent $WarningPercent)
$channelPlan=@()
if ($EnablePrivacyChannel) {
$one=@($plan | Where-Object {$_.Definition.Id -eq 'OneSettings'})[0]
if ($one.Before.Status -ne 'Supported') { throw "Privacy channel configuration unavailable: $($one.Before.Diagnostic)" }
# WELA's technical minimum only; CIS does not prescribe a channel size here.
$profile=[pscustomobject]@{controls=@([pscustomobject]@{channel=$one.Definition.Channel;enabled=$true;sourceExampleBytes=65536;readerSid=$null})}
$channelPlan=@(Get-WelaNativeChannelPlan -Profile $profile)
}
$report=[pscustomobject]@{Scope='audit-notifications';ExitCode=$(if (@($plan | Where-Object Status -eq 'Unknown').Count) {1} else {0})}
if ($Action -eq 'Configure') {
$context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
Set-WelaNotificationControls $context $plan
if ($EnablePrivacyChannel) {
$policy=@($context.Results | Where-Object Id -eq 'AuditNotifications/OneSettings')[0]
if ($policy.Status -in @('AlreadyCompliant','Applied') -or ($DryRun -and $policy.Status -eq 'Skipped' -and $policy.Diagnostic -like 'Dry run:*')) {
# Recheck the complete producer prerequisites before any channel action.
try {
$fresh=Get-WelaNotificationSnapshot $one.Definition
if ($fresh.Status -ne 'Supported' -or (-not $DryRun -and
(-not $fresh.Policy.ValueExists -or $fresh.Policy.Type -ne 'DWord' -or $fresh.Policy.Value -ne 1))) {
throw 'Privacy channel prerequisites changed or OneSettings policy is no longer enabled; dependent channel action refused.'
}
foreach ($channel in $channelPlan) {
$channel | Add-Member NoteProperty NotificationDefinition $one.Definition
$channel | Add-Member NoteProperty NotificationPreview ([bool]$DryRun)
}
$guard={ param($channel)
$producer=Get-WelaNotificationSnapshot $channel.NotificationDefinition
if ($producer.Status -ne 'Supported' -or (-not $channel.NotificationPreview -and
(-not $producer.Policy.ValueExists -or $producer.Policy.Type -ne 'DWord' -or $producer.Policy.Value -ne 1))) {
throw 'OneSettings policy/prerequisites changed before the dependent channel action or verification.'
}
}
Set-WelaNativeChannelControls -Context $context -Plan $channelPlan -Profile 'audit-notifications' -ValidatePrerequisites $guard
} catch {
# Keep the policy journal, final drift check and JSON export available.
$context.Results.Add([pscustomobject]@{Id='AuditNotifications/PrivacyChannelDependency';Kind='NativeChannel';Target=$one.Definition.Channel;Desired='Enable after verified OneSettings policy';Before=$fresh;After=$null;Status='Failed';Diagnostic=$_.Exception.Message})
}
}
}
$report=Complete-WelaConfiguration -Context $context -SuccessMessage 'Notification configuration finished. Applied/AlreadyCompliant rows verify settings; skipped rows do not. Events and forwarding remain unverified.'
$report.Scope='audit-notifications'
}
$report | Add-Member NoteProperty Action $Action
$report | Add-Member NoteProperty Plan $plan
$report | Add-Member NoteProperty PrivacyChannelPlan $channelPlan
$report | Add-Member NoteProperty Current @(Get-WelaNotificationPlan -Control $Control -WarningPercent $WarningPercent)
$report | Add-Member NoteProperty EventGeneration 'Not verified; no Sigma eligibility increase. Sysmon is out of scope.'
if ($ResultsPath) { $report | ConvertTo-Json -Depth 18 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
$report
}