mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-02 20:44:42 +02:00
* Add opt-in source-profile audit integrity controls * Reference PR 412 in audit-integrity changelogs
193 lines
16 KiB
PowerShell
193 lines
16 KiB
PowerShell
# Explicit source-profile audit integrity. Direct assignments are not token membership.
|
|
function Initialize-WelaIntegrityNative {
|
|
if (-not ('Wela.AuditIntegrityNative' -as [type])) {
|
|
Add-Type -Path (Join-Path $PSScriptRoot 'AuditIntegrityNative.cs') -ErrorAction Stop
|
|
}
|
|
}
|
|
function Get-WelaIntegrityHolders {
|
|
param([ValidateSet('SeAuditPrivilege','SeSecurityPrivilege')][string]$Right)
|
|
Initialize-WelaIntegrityNative
|
|
return ,@([Wela.AuditIntegrityNative]::Holders($Right))
|
|
}
|
|
function Get-WelaIntegrityAccountRights {
|
|
param([string]$Sid)
|
|
Initialize-WelaIntegrityNative
|
|
return ,@([Wela.AuditIntegrityNative]::Rights($Sid))
|
|
}
|
|
function Set-WelaIntegrityAccountRight {
|
|
param([string]$Sid,[ValidateSet('SeAuditPrivilege','SeSecurityPrivilege')][string]$Right,[bool]$Grant)
|
|
Initialize-WelaIntegrityNative
|
|
[Wela.AuditIntegrityNative]::Change($Sid,$Right,$Grant)
|
|
}
|
|
function Get-WelaIntegrityHost {
|
|
if ($env:OS -ne 'Windows_NT') { throw 'Audit integrity requires Windows.' }
|
|
$os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
|
|
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
|
if ($os.ProductType -notin @(1,2,3) -or $computer.DomainRole -notin @(0,1,2,3,4,5) -or $null -eq $computer.PartOfDomain -or [string]$os.BuildNumber -notmatch '^\d+$') { throw 'Unknown Windows role/build.' }
|
|
$role=$null
|
|
if ($os.ProductType -eq 1 -and $computer.DomainRole -in @(0,1)) { $role='Client' }
|
|
elseif ($os.ProductType -eq 2 -and $computer.DomainRole -in @(4,5) -and $computer.PartOfDomain) { $role='DomainController' }
|
|
elseif ($os.ProductType -eq 3 -and $computer.DomainRole -eq 3 -and $computer.PartOfDomain) { $role='MemberServer' }
|
|
elseif ($os.ProductType -eq 3 -and $computer.DomainRole -eq 2 -and -not $computer.PartOfDomain) { $role='StandaloneServer' }
|
|
if (-not $role -or ($computer.DomainRole -eq 1 -and -not $computer.PartOfDomain) -or ($computer.DomainRole -eq 0 -and $computer.PartOfDomain)) { throw 'Conflicting Windows role observations.' }
|
|
if (-not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell for this workflow.' }
|
|
$ca='Unknown'
|
|
try { $ca=if (@(Get-CimInstance Win32_Service -Filter "Name='CertSvc'" -ErrorAction Stop).Count) { 'Installed' } else { 'NotInstalled' } } catch { }
|
|
[pscustomobject]@{Status='Known';ComputerName=[string]$computer.Name;Role=$role;Build=[int]$os.BuildNumber;DomainJoined=[bool]$computer.PartOfDomain;DomainRole=[int]$computer.DomainRole;CertificateAuthority=$ca}
|
|
}
|
|
function Get-WelaIntegritySnapshot {
|
|
param([string[]]$ObserveSids=@())
|
|
$errors=@();$hostState=$null;$rights=@();$accounts=@();$crash=$null
|
|
try { $hostState=Get-WelaIntegrityHost } catch { $hostState=[pscustomobject]@{Status='Unknown';Diagnostic=$_.Exception.Message};$errors+=$_.Exception.Message }
|
|
if ($hostState.Status -eq 'Known') {
|
|
foreach ($right in @('SeAuditPrivilege','SeSecurityPrivilege')) {
|
|
try { $holders=@(Get-WelaIntegrityHolders $right | ForEach-Object {$_} | Sort-Object -Unique);$rights+=[pscustomobject]@{Name=$right;Holders=$holders;Status='Known'} }
|
|
catch { $rights+=[pscustomobject]@{Name=$right;Holders=@();Status='Unknown'};$errors+="$right : $($_.Exception.Message)" }
|
|
}
|
|
$sids=@(@('S-1-5-19','S-1-5-20','S-1-5-32-544') + @($rights | ForEach-Object {$_.Holders}) + @($ObserveSids) | Sort-Object -Unique)
|
|
foreach ($sid in $sids) {
|
|
try { $assigned=@(Get-WelaIntegrityAccountRights $sid | ForEach-Object {$_} | Sort-Object -Unique);$accounts+=[pscustomobject]@{Sid=$sid;Rights=$assigned;Status='Known'} }
|
|
catch { $accounts+=[pscustomobject]@{Sid=$sid;Rights=@();Status='Unknown'};$errors+="$sid : $($_.Exception.Message)" }
|
|
}
|
|
if (-not $errors.Count) {
|
|
foreach ($right in $rights) {
|
|
$fromAccounts=@($accounts | Where-Object { $_.Rights -contains $right.Name } | ForEach-Object {$_.Sid} | Sort-Object -Unique)
|
|
if (($right.Holders -join '|') -cne ($fromAccounts -join '|')) { $errors+='LSA holder and per-account observations differ; retry a consistent snapshot.' }
|
|
}
|
|
}
|
|
try { $crash=Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name CrashOnAuditFail }
|
|
catch { $errors+="CrashOnAuditFail : $($_.Exception.Message)" }
|
|
}
|
|
[pscustomobject]@{Host=$hostState;Rights=$rights;Accounts=$accounts;CrashOnAuditFail=$crash;Errors=$errors}
|
|
}
|
|
function Get-WelaIntegrityProfiles {
|
|
$catalog=Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/audit_integrity_profiles.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
if ($catalog.SchemaVersion -ne 1) { throw 'Unsupported integrity profile catalog.' }
|
|
return @($catalog.Profiles)
|
|
}
|
|
function Get-WelaIntegrityStateKey {
|
|
param($Snapshot)
|
|
ConvertTo-Json -InputObject $Snapshot -Depth 14 -Compress
|
|
}
|
|
function Get-WelaIntegrityPrincipal {
|
|
param([string]$Sid)
|
|
$name=switch ($Sid) { 'S-1-5-19' {'LOCAL SERVICE'} 'S-1-5-20' {'NETWORK SERVICE'} 'S-1-5-32-544' {'BUILTIN\Administrators'} default {$null} }
|
|
if (-not $name -and $env:OS -eq 'Windows_NT') { try { $name=([Security.Principal.SecurityIdentifier]::new($Sid)).Translate([Security.Principal.NTAccount]).Value } catch { } }
|
|
[pscustomobject]@{Sid=$Sid;Name=$name;NameStatus=$(if ($name) {'Resolved'} else {'Unresolved; SID remains authoritative'})}
|
|
}
|
|
function Get-WelaIntegrityPlan {
|
|
param($Snapshot,[string]$Profile,[switch]$AllowPrivilegeRemoval)
|
|
$source=$null;$blockers=@($Snapshot.Errors);$operations=@();$rows=@()
|
|
if ($Profile) {
|
|
$matches=@(Get-WelaIntegrityProfiles | Where-Object Id -eq $Profile)
|
|
if ($matches.Count -ne 1) { throw "Unknown audit integrity profile: $Profile" }
|
|
$source=$matches[0]
|
|
if ($Snapshot.Host.Status -ne 'Known' -or $source.Role -ne $Snapshot.Host.Role -or $Snapshot.Host.Build -lt $source.MinBuild -or $Snapshot.Host.Build -gt $source.MaxBuild) { $blockers+='Selected source profile does not match the actual Windows role/build.' }
|
|
}
|
|
foreach ($right in @('SeAuditPrivilege','SeSecurityPrivilege')) {
|
|
$observed=@($Snapshot.Rights | Where-Object Name -eq $right)
|
|
$current=@(if ($observed.Count -eq 1) { $observed[0].Holders })
|
|
$desired=$null; if ($source -and $null -ne $source.$right) { $desired=@($source.$right | Sort-Object -Unique) }
|
|
$adds=@();$removes=@();$mode='Preserve'
|
|
if ($null -ne $desired) {
|
|
$mode='Exact';$adds=@($desired | Where-Object { $current -notcontains $_ });$removes=@($current | Where-Object { $desired -notcontains $_ })
|
|
if ($removes.Count -and -not $AllowPrivilegeRemoval) { $blockers+="$right has extra principals; inspect service/dependency exceptions and explicitly use -AllowPrivilegeRemoval before revoking them." }
|
|
foreach ($sid in $adds) { $operations+=[pscustomobject]@{Kind='Right';Right=$right;Sid=$sid;Grant=$true} }
|
|
foreach ($sid in $removes) { $operations+=[pscustomobject]@{Kind='Right';Right=$right;Sid=$sid;Grant=$false} }
|
|
}
|
|
$rows+=[pscustomobject]@{Name=$right;Mode=$mode;SourceSetting=$(if (-not $source) {'NoSourceSelected'} elseif ($null -eq $desired) {'OmittedBySource'} else {'ExplicitRequirement'});ObservedStatus=$(if ($observed.Count) {$observed[0].Status} else {'Unknown'});Current=$current;Desired=$desired;Add=@($adds|ForEach-Object {Get-WelaIntegrityPrincipal $_});Remove=@($removes|ForEach-Object {Get-WelaIntegrityPrincipal $_})}
|
|
}
|
|
$crash=$Snapshot.CrashOnAuditFail;$crashStatus='Unknown'
|
|
if ($crash -and $crash.KeyExists) {
|
|
if (-not $crash.ValueExists) { $crashStatus='Absent; no explicit value observed' }
|
|
elseif ($crash.Type -eq 'DWord' -and $crash.Value -in @(0,1)) { $crashStatus=if ($crash.Value -eq 0) {'Disabled'} else {'Enabled'} }
|
|
elseif ($crash.Type -eq 'DWord' -and $crash.Value -eq 2) { $crashStatus='RecoveryRequired';$blockers+='CrashOnAuditFail=2 is a recovery state. This workflow never resets it or clears the Security log.' }
|
|
else { $blockers+='Unknown CrashOnAuditFail type/value; preserve it for manual review.' }
|
|
} else { $blockers+='LSA registry key/state could not be verified.' }
|
|
$crashMode='Preserve';$crashDesired=$null
|
|
if ($source -and $null -ne $source.CrashOnAuditFail) {
|
|
if ($source.CrashOnAuditFail -ne 0) { throw 'Only reviewed disabled CrashOnAuditFail profiles are supported.' }
|
|
$crashMode='Exact';$crashDesired=0
|
|
if ($crashStatus -ne 'Disabled') { $operations+=[pscustomobject]@{Kind='Registry';Name='CrashOnAuditFail';Desired=0} }
|
|
}
|
|
$rows+=[pscustomobject]@{Name='CrashOnAuditFail';Mode=$crashMode;SourceSetting=$(if (-not $source) {'NoSourceSelected'} elseif ($null -eq $crashDesired) {'OmittedBySource'} else {'ExplicitRequirement'});ObservedStatus=$crashStatus;Current=$crash;Desired=$crashDesired;Add=@();Remove=@()}
|
|
[pscustomobject]@{Profile=$source;Before=$Snapshot;Controls=$rows;Operations=$operations;Blockers=@($blockers|Select-Object -Unique);AllowPrivilegeRemoval=[bool]$AllowPrivilegeRemoval;
|
|
Exceptions=@('IIS application pools can require SeAuditPrivilege.','AD FS service identities can require SeAuditPrivilege.','Exchange Servers can require SeSecurityPrivilege on DCs.','Other application dependencies must be reviewed before any removal.');
|
|
VerificationScope='Local LSA direct assignments and typed registry readback only. Existing tokens, GPO persistence, benign event generation, ingestion and effective user/group access are not verified.'}
|
|
}
|
|
function Copy-WelaIntegrityExpected {
|
|
param($Snapshot)
|
|
Get-WelaIntegrityStateKey $Snapshot | ConvertFrom-Json
|
|
}
|
|
function Update-WelaIntegrityExpected {
|
|
param($Snapshot,$Operation)
|
|
if ($Operation.Kind -eq 'Registry') {
|
|
$Snapshot.CrashOnAuditFail.ValueExists=$true;$Snapshot.CrashOnAuditFail.Value=0;$Snapshot.CrashOnAuditFail.Type='DWord'
|
|
} else {
|
|
$right=@($Snapshot.Rights|Where-Object Name -eq $Operation.Right)[0]
|
|
$account=@($Snapshot.Accounts|Where-Object Sid -eq $Operation.Sid)[0]
|
|
if (-not $right -or -not $account) { throw 'Missing affected principal in the recovery snapshot.' }
|
|
if ($Operation.Grant) { $right.Holders=@(@($right.Holders)+$Operation.Sid|Sort-Object -Unique);$account.Rights=@(@($account.Rights)+$Operation.Right|Sort-Object -Unique) }
|
|
else { $right.Holders=@($right.Holders|Where-Object {$_ -ne $Operation.Sid});$account.Rights=@($account.Rights|Where-Object {$_ -ne $Operation.Right}) }
|
|
}
|
|
}
|
|
function Set-WelaIntegrityControls {
|
|
param($Context,$Plan)
|
|
$expected=Copy-WelaIntegrityExpected $Plan.Before
|
|
foreach ($operation in $Plan.Operations) { Update-WelaIntegrityExpected $expected $operation }
|
|
$state=@{Plan=$Plan;Expected=$expected;ObserveSids=@($Plan.Before.Accounts.Sid)}
|
|
$read={param($s) Get-WelaIntegritySnapshot -ObserveSids $s.ObserveSids}
|
|
$test={param($snapshot,$s) -not $s.Plan.Blockers.Count -and (Get-WelaIntegrityStateKey $snapshot) -ceq (Get-WelaIntegrityStateKey $s.Expected)}
|
|
$apply={
|
|
param($s)
|
|
if ($s.Plan.Blockers.Count) { throw ($s.Plan.Blockers -join ' ') }
|
|
$expected=Copy-WelaIntegrityExpected $s.Plan.Before
|
|
if ((Get-WelaIntegrityStateKey (Get-WelaIntegritySnapshot -ObserveSids $s.ObserveSids)) -cne (Get-WelaIntegrityStateKey $expected)) { throw 'Audit-integrity state changed since planning; no write was sent.' }
|
|
foreach ($operation in $s.Plan.Operations) {
|
|
$fresh=Get-WelaIntegritySnapshot -ObserveSids $s.ObserveSids
|
|
if ((Get-WelaIntegrityStateKey $fresh) -cne (Get-WelaIntegrityStateKey $expected)) { throw 'Audit-integrity state changed before the next write; remaining operations stopped.' }
|
|
if ($operation.Kind -eq 'Right') { Set-WelaIntegrityAccountRight -Sid $operation.Sid -Right $operation.Right -Grant $operation.Grant }
|
|
else { Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name CrashOnAuditFail -Value 0 -Type DWord -ErrorAction Stop }
|
|
Update-WelaIntegrityExpected $expected $operation
|
|
$after=Get-WelaIntegritySnapshot -ObserveSids $s.ObserveSids
|
|
if ((Get-WelaIntegrityStateKey $after) -cne (Get-WelaIntegrityStateKey $expected)) { throw 'Audit-integrity write/readback or unrelated-right preservation did not verify; remaining operations stopped. Review the recovery journal.' }
|
|
}
|
|
}
|
|
Invoke-WelaConfigurationControl -Context $Context -Id 'AuditIntegrity/LocalPolicy' -Kind 'AuditIntegritySet' -Target @('SeAuditPrivilege','SeSecurityPrivilege','CrashOnAuditFail') -Desired $Plan `
|
|
-Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Apply the exact listed local privilege additions/removals and selected CrashOnAuditFail policy. Review every affected SID and service exception first.'
|
|
}
|
|
function Invoke-WelaIntegrityCommand {
|
|
param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string]$Profile,[switch]$AllowPrivilegeRemoval,[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath)
|
|
if ($Action -ne 'Audit' -and -not $Profile) { throw 'Plan and Configure require an explicit -IntegrityProfile.' }
|
|
if ($AllowPrivilegeRemoval -and (-not $Profile -or $Action -eq 'Audit')) { throw '-AllowPrivilegeRemoval requires an explicit profile with Plan or Configure.' }
|
|
if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun requires IntegrityAction Configure; Audit and Plan are read-only.' }
|
|
$snapshot=Get-WelaIntegritySnapshot
|
|
$plan=Get-WelaIntegrityPlan -Snapshot $snapshot -Profile $Profile -AllowPrivilegeRemoval:$AllowPrivilegeRemoval
|
|
$report=[pscustomobject]@{Scope='audit-integrity-local-policy-only';ExitCode=$(if ($plan.Blockers.Count) {1} else {0});Action=$Action;Plan=$plan}
|
|
# Show complete affected principals before the shared runner asks for consent.
|
|
Write-Host "Observed host: $($snapshot.Host.ComputerName); role/build: $($snapshot.Host.Role)/$($snapshot.Host.Build); source profile: $Profile"
|
|
Write-Host 'Plan observations (before any configuration):'
|
|
foreach ($row in $plan.Controls) {
|
|
Write-Host "$($row.Name): $($row.Mode); $($row.SourceSetting); $($row.ObservedStatus)"
|
|
Write-Host (' Current: ' + (ConvertTo-Json -InputObject $row.Current -Depth 4 -Compress))
|
|
if ($row.Mode -eq 'Exact') { Write-Host (' Requested: ' + (ConvertTo-Json -InputObject $row.Desired -Compress)) }
|
|
foreach ($principal in $row.Add) { Write-Host " ADD: $($principal.Sid) ($($principal.Name))" }
|
|
foreach ($principal in $row.Remove) { Write-Host " REMOVE: $($principal.Sid) ($($principal.Name))" }
|
|
}
|
|
if (@($plan.Controls.Remove).Count) { Write-Host ($plan.Exceptions -join ' ') }
|
|
foreach ($blocker in $plan.Blockers) { Write-Host "Blocked: $blocker" -ForegroundColor Yellow }
|
|
Write-Host $plan.VerificationScope
|
|
if ($Action -eq 'Configure') {
|
|
# Refuse unresolved scope/recovery/removal decisions even before creating a journal directory.
|
|
if ($plan.Blockers.Count) { throw ($plan.Blockers -join ' ') }
|
|
$context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
|
|
Set-WelaIntegrityControls -Context $context -Plan $plan
|
|
$report=Complete-WelaConfiguration -Context $context -Scope 'audit-integrity-local-policy-only' -SuccessMessage 'Audit-integrity configuration completed. Applied/AlreadyCompliant rows verify local settings; skipped rows do not. Token, GPO and event evidence remain separate checks.'
|
|
$report|Add-Member NoteProperty Action $Action
|
|
$report|Add-Member NoteProperty Plan $plan
|
|
}
|
|
$report|Add-Member NoteProperty SigmaEvtxCredit 0
|
|
if ($ResultsPath) { $report|ConvertTo-Json -Depth 18|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
|
|
return $report
|
|
}
|