mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-01 20:14:43 +02:00
552 lines
32 KiB
PowerShell
552 lines
32 KiB
PowerShell
# Execution helpers for configure. Compatible with Windows PowerShell 5.1.
|
|
function Invoke-WelaNative {
|
|
param([string]$FilePath, [string[]]$Arguments)
|
|
# Windows PowerShell sends native stderr through the error stream. Collect it
|
|
# without treating stderr alone as failure; the process exit code is decisive.
|
|
$ErrorActionPreference = 'Continue'
|
|
$PSNativeCommandUseErrorActionPreference = $false
|
|
$null = Get-Command $FilePath -ErrorAction Stop
|
|
$global:LASTEXITCODE = $null
|
|
$output = @(& $FilePath @Arguments 2>&1)
|
|
$exitCode = $global:LASTEXITCODE # Capture immediately, before invoking anything else.
|
|
$diagnostic = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
|
|
if ($null -eq $exitCode -or $exitCode -ne 0) {
|
|
throw "$FilePath $($Arguments -join ' ') failed (exit: $exitCode). $diagnostic"
|
|
}
|
|
[pscustomobject]@{ ExitCode = $exitCode; Output = $output; Diagnostic = $diagnostic }
|
|
}
|
|
|
|
function New-WelaConfigurationContext {
|
|
param([switch]$Auto, [switch]$DryRun, [string]$BackupPath)
|
|
if (-not $DryRun) {
|
|
if (-not $BackupPath) {
|
|
$BackupPath = Join-Path $script:ScriptRoot ("wela-backup-{0}-{1}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N'))
|
|
}
|
|
# Refuse reuse: a prior run's recovery evidence must never be overwritten.
|
|
$null = New-Item -ItemType Directory -Path $BackupPath -ErrorAction Stop
|
|
$BackupPath = (Resolve-Path -LiteralPath $BackupPath -ErrorAction Stop).Path
|
|
}
|
|
[pscustomobject]@{
|
|
Auto = [bool]$Auto; DryRun = [bool]$DryRun; BackupPath = $BackupPath
|
|
Results = New-Object 'System.Collections.Generic.List[object]'
|
|
Checks = New-Object 'System.Collections.Generic.List[object]'
|
|
}
|
|
}
|
|
|
|
function Assert-WelaConfigurationProfileGuard {
|
|
param($Context)
|
|
if ($Context.PSObject.Properties['CustomProfileGuard']) {
|
|
$guard = $Context.CustomProfileGuard
|
|
Assert-WelaCustomProfileSource $guard.Source
|
|
$actual = Get-WelaHostContext
|
|
if ($actual.Role -ne $guard.Role -or $actual.Build -ne $guard.Build) { throw 'Custom profile target role/build changed; no further configuration is authorized.' }
|
|
}
|
|
}
|
|
|
|
function Invoke-WelaConfigurationControl {
|
|
param($Context, [string]$Id, [string]$Kind, $Target, $Desired,
|
|
[scriptblock]$Read, [scriptblock]$Compliant, [scriptblock]$Apply,
|
|
[string]$Description = '', [scriptblock]$PreserveWhen, $CallbackState)
|
|
$result = [pscustomobject][ordered]@{
|
|
Id = $Id; Kind = $Kind; Target = $Target; Desired = $Desired
|
|
Before = $null; After = $null; Status = 'Failed'; Diagnostic = ''
|
|
}
|
|
try {
|
|
Assert-WelaConfigurationProfileGuard $Context
|
|
$result.Before = & $Read $CallbackState
|
|
$preserveReason = if ($PreserveWhen) { & $PreserveWhen $result.Before } else { $null }
|
|
if ($preserveReason) {
|
|
$result.Status = 'Skipped'; $result.After = $result.Before; $result.Diagnostic = [string]$preserveReason
|
|
} elseif (& $Compliant $result.Before $CallbackState) {
|
|
$result.Status = 'AlreadyCompliant'
|
|
$result.After = $result.Before
|
|
} elseif ($Context.DryRun) {
|
|
$result.Status = 'Skipped'; $result.Diagnostic = 'Dry run: change required; no write or restart performed.'
|
|
} else {
|
|
$proceed = $Context.Auto
|
|
if (-not $proceed) {
|
|
$response = Read-Host "$Id : $Description Apply this change? (Y/n)"
|
|
$proceed = ($response -eq '' -or $response -match '^[Yy]$')
|
|
}
|
|
if (-not $proceed) {
|
|
$result.Status = 'Skipped'; $result.Diagnostic = 'Declined by operator.'
|
|
} else {
|
|
# Persist the exact pre-change value before any mutation. A journal
|
|
# failure stops this control, including service restarts.
|
|
$entry = [ordered]@{
|
|
Version = 1; ComputerName = $env:COMPUTERNAME
|
|
RecordedUtc = [DateTime]::UtcNow.ToString('o')
|
|
Id = $Id; Kind = $Kind; Target = $Target
|
|
Before = $result.Before; Desired = $Desired
|
|
}
|
|
if ($Context.PSObject.Properties['CustomProfileGuard']) { $entry.CustomProfileSource = $Context.CustomProfileGuard.Source }
|
|
$entry | ConvertTo-Json -Depth 12 -Compress |
|
|
Add-Content -LiteralPath (Join-Path $Context.BackupPath 'before.jsonl') -Encoding UTF8 -ErrorAction Stop
|
|
Assert-WelaConfigurationProfileGuard $Context
|
|
$applied = @(& $Apply $CallbackState)
|
|
$result.Diagnostic = ($applied | ForEach-Object {
|
|
if ($_.PSObject.Properties['Diagnostic']) { $_.Diagnostic } else { $_.ToString() }
|
|
}) -join [Environment]::NewLine
|
|
Assert-WelaConfigurationProfileGuard $Context
|
|
$result.After = & $Read $CallbackState
|
|
if (-not (& $Compliant $result.After $CallbackState)) {
|
|
throw "Post-apply verification did not match the requested state. $($result.Diagnostic)"
|
|
}
|
|
$result.Status = 'Applied'
|
|
}
|
|
}
|
|
if ($result.Status -in @('Applied', 'AlreadyCompliant')) {
|
|
$Context.Checks.Add([pscustomobject]@{ Result = $result; Read = $Read; Compliant = $Compliant; CallbackState = $CallbackState })
|
|
}
|
|
} catch {
|
|
$result.Status = 'Failed'; $result.Diagnostic = $_.ToString()
|
|
}
|
|
$Context.Results.Add($result)
|
|
$color = if ($result.Status -eq 'Failed') { 'Red' } elseif ($result.Status -eq 'Skipped') { 'Yellow' } else { 'Green' }
|
|
Write-Host "[$($result.Status)] $Id $($result.Diagnostic)" -ForegroundColor $color
|
|
}
|
|
|
|
function Complete-WelaConfiguration {
|
|
param($Context, [string]$ResultsPath, $Plan,
|
|
[ValidateSet("native-windows-configuration", "process-commandline-policy-only", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "windows-powershell-event-logging-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")]
|
|
[string]$Scope = "native-windows-configuration",
|
|
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
|
|
if ($Context.PSObject.Properties['CustomProfileGuard']) {
|
|
try { Assert-WelaConfigurationProfileGuard $Context }
|
|
catch { $Context.Results.Add([pscustomobject]@{Id='CustomProfile/FinalValidation';Kind='ProfileSource';Target=$Context.CustomProfileGuard.Source;Desired='Unchanged file and target';Before=$null;After=$null;Status='Failed';Diagnostic=$_.ToString()}) }
|
|
}
|
|
# 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) {
|
|
try {
|
|
Assert-WelaConfigurationProfileGuard $Context
|
|
$check.Result.After = & $check.Read $check.CallbackState
|
|
if (-not (& $check.Compliant $check.Result.After $check.CallbackState)) {
|
|
$check.Result.Status = 'Overridden'
|
|
$check.Result.Diagnostic = 'State was compliant earlier but changed before the final check; cause unknown.'
|
|
}
|
|
} catch {
|
|
$check.Result.Status = 'Failed'
|
|
$check.Result.Diagnostic = "Final verification failed: $_"
|
|
}
|
|
}
|
|
$failed = @($Context.Results | Where-Object { $_.Status -in @('Failed', 'Overridden') }).Count
|
|
$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; 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 ProfileScope $Plan.scope
|
|
if ($Plan.PSObject.Properties['CustomProfileSource']) { $report | Add-Member NoteProperty CustomProfileSource $Plan.CustomProfileSource }
|
|
}
|
|
if ($ResultsPath) {
|
|
try { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
|
|
catch { $report.ExitCode = 1; Write-Host "[Failed] Writing results: $_" -ForegroundColor Red }
|
|
}
|
|
if ($report.ExitCode) { Write-Host "Configuration incomplete: $failed failed or overridden control(s). Review results and recovery journal." -ForegroundColor Red }
|
|
elseif ($Context.DryRun) { Write-Host 'Dry run completed. No Windows configuration was changed.' -ForegroundColor Cyan }
|
|
elseif ($skipped) { Write-Host "Configuration completed with $skipped skipped control(s)." -ForegroundColor Yellow }
|
|
else { Write-Host $SuccessMessage -ForegroundColor Green }
|
|
return $report
|
|
}
|
|
|
|
function Set-WelaEventLogControl {
|
|
param($Context, [string]$Log, [string]$Property, $Desired)
|
|
$state = @{ Log = $Log; Property = $Property; Desired = $Desired }
|
|
# Explicit callback state preserves values for the final recheck without
|
|
# GetNewClosure's dynamic-module scope, which hides script-local helpers in 5.1.
|
|
$read = { param($state) (Get-WinEvent -ListLog $state.Log -ErrorAction Stop).($state.Property) }
|
|
$test = {
|
|
param($value, $state)
|
|
if ($state.Property -eq 'MaximumSizeInBytes') { return $value -ge $state.Desired }
|
|
return $value -eq $state.Desired
|
|
}
|
|
$apply = {
|
|
param($state)
|
|
$argument = if ($state.Property -eq 'MaximumSizeInBytes') { "/ms:$($state.Desired)" } else { '/e:true' }
|
|
Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments @('sl', $state.Log, $argument)
|
|
}
|
|
Invoke-WelaConfigurationControl -Context $Context -Id "EventLog/$Log/$Property" -Kind EventLog `
|
|
-Target @{ Log = $Log; Property = $Property } -Desired $Desired -Read $read -Compliant $test -Apply $apply -CallbackState $state
|
|
}
|
|
|
|
function Get-WelaRegistryState {
|
|
param([string]$Path, [string]$Name)
|
|
if (-not (Test-Path -LiteralPath $Path -ErrorAction Stop)) {
|
|
return [pscustomobject]@{ KeyExists = $false; ValueExists = $false; Value = $null; Type = $null }
|
|
}
|
|
$key = Get-Item -LiteralPath $Path -ErrorAction Stop
|
|
if ($key.GetValueNames() -notcontains $Name) {
|
|
return [pscustomobject]@{ KeyExists = $true; ValueExists = $false; Value = $null; Type = $null }
|
|
}
|
|
[pscustomobject]@{
|
|
KeyExists = $true; ValueExists = $true
|
|
Value = $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
Type = $key.GetValueKind($Name).ToString()
|
|
}
|
|
}
|
|
|
|
function New-WelaRegistryKey {
|
|
param([string]$Path)
|
|
if (Test-Path -LiteralPath $Path -ErrorAction Stop) { return }
|
|
$separator = $Path.TrimEnd('\').LastIndexOf('\')
|
|
if ($separator -lt 1) { throw "Registry root is unavailable: $Path" }
|
|
$parent = $Path.Substring(0, $separator)
|
|
# Registry New-Item without Force requires its immediate parent. Build only
|
|
# missing ancestors; never run New-Item -Force against an existing key.
|
|
New-WelaRegistryKey -Path $parent
|
|
$null = New-Item -Path $Path -ErrorAction Stop
|
|
}
|
|
|
|
function Set-WelaRegistryControl {
|
|
param($Context, [string]$Path, [string]$Name, $Value, [string]$Type = 'DWord', [scriptblock]$PreserveWhen)
|
|
$state = @{ Path = $Path; Name = $Name; Value = $Value; Type = $Type; PreserveWhen = $PreserveWhen }
|
|
$read = { param($state) Get-WelaRegistryState -Path $state.Path -Name $state.Name }
|
|
$test = { param($value, $state) $value.ValueExists -and $value.Value -eq $state.Value -and $value.Type -eq $state.Type }
|
|
$apply = {
|
|
param($state)
|
|
New-WelaRegistryKey -Path $state.Path
|
|
if ($state.PreserveWhen) {
|
|
# Recheck after the prompt and journal, immediately before the value write.
|
|
$fresh = Get-WelaRegistryState -Path $state.Path -Name $state.Name
|
|
$preserveReason = & $state.PreserveWhen $fresh
|
|
if ($preserveReason) { throw "Refused registry write after state changed: $preserveReason" }
|
|
}
|
|
Set-ItemProperty -LiteralPath $state.Path -Name $state.Name -Value $state.Value -Type $state.Type -ErrorAction Stop
|
|
}
|
|
Invoke-WelaConfigurationControl -Context $Context -Id "Registry/$Path/$Name" -Kind Registry `
|
|
-Target @{ Path = $Path; Name = $Name } -Desired @{ Value = $Value; Type = $Type } `
|
|
-Read $read -Compliant $test -Apply $apply -PreserveWhen $PreserveWhen -CallbackState $state
|
|
}
|
|
|
|
function Initialize-WelaConfigurationAuditApi {
|
|
if ('Wela.ConfigurationAuditApi' -as [type]) { return }
|
|
# Querying the Windows API avoids localized auditpol /get CSV (six columns;
|
|
# unlike /backup output, it has no numeric Setting Value column).
|
|
Add-Type -TypeDefinition @'
|
|
using System;
|
|
using System.ComponentModel;
|
|
using System.Runtime.InteropServices;
|
|
namespace Wela {
|
|
public static class ConfigurationAuditApi {
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct AuditPolicyInformation {
|
|
public Guid Subcategory;
|
|
public UInt32 Information;
|
|
public Guid Category;
|
|
}
|
|
[DllImport("advapi32.dll", SetLastError = true)]
|
|
[return: MarshalAs(UnmanagedType.U1)]
|
|
private static extern bool AuditQuerySystemPolicy(
|
|
[In] Guid[] subcategories, UInt32 count, out IntPtr policy);
|
|
[DllImport("advapi32.dll")]
|
|
private static extern void AuditFree(IntPtr buffer);
|
|
public static UInt32 Query(Guid subcategory) {
|
|
IntPtr buffer = IntPtr.Zero;
|
|
if (!AuditQuerySystemPolicy(new Guid[] { subcategory }, 1, out buffer)) {
|
|
throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
}
|
|
try {
|
|
if (buffer == IntPtr.Zero) throw new InvalidOperationException("Audit policy query returned no buffer.");
|
|
AuditPolicyInformation policy = (AuditPolicyInformation)Marshal.PtrToStructure(buffer, typeof(AuditPolicyInformation));
|
|
if (policy.Subcategory != subcategory) throw new InvalidOperationException("Audit policy query returned a different subcategory.");
|
|
return policy.Information;
|
|
} finally {
|
|
if (buffer != IntPtr.Zero) AuditFree(buffer);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
'@ -ErrorAction Stop
|
|
}
|
|
|
|
function Get-WelaNativeAuditPolicy {
|
|
param([string]$Guid)
|
|
Initialize-WelaConfigurationAuditApi
|
|
return [Wela.ConfigurationAuditApi]::Query([guid]$Guid)
|
|
}
|
|
|
|
function Get-WelaAuditPolicyMask {
|
|
param([string]$Guid)
|
|
$flags = Get-WelaNativeAuditPolicy -Guid $Guid
|
|
if ($flags -notin @(0, 1, 2, 3, 4)) { throw "Unexpected audit policy flags $flags for $Guid." }
|
|
# POLICY_AUDIT_EVENT_NONE is 4; the success/failure mask is zero.
|
|
return [int]($flags -band 3)
|
|
}
|
|
|
|
function Set-WelaAuditPolicyControl {
|
|
param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3,
|
|
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence,
|
|
$IpsecObservations, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite })
|
|
$guid = $Policy.GUID
|
|
$state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence; IpsecObservations=$IpsecObservations; ReadIpsec=$ReadIpsec }
|
|
$read = { param($state)
|
|
if ($null -ne $state.IpsecObservations) {
|
|
$evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence)
|
|
Assert-WelaIpsecPrerequisite $evidence
|
|
}
|
|
Get-WelaAuditPolicyMask -Guid $state.Guid
|
|
}
|
|
$test = {
|
|
param($value, $state)
|
|
if ($state.Mode -eq 'minimum') { return ($value -band $state.Mask) -eq $state.Mask }
|
|
return $value -eq $state.Mask
|
|
}
|
|
$apply = {
|
|
param($state)
|
|
if ($state.RequirePrecedence) {
|
|
$precedence = Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy
|
|
if (-not $precedence.ValueExists -or $precedence.Type -ne 'DWord' -or $precedence.Value -ne 1) {
|
|
throw 'Audit precedence changed before the write; subcategory policy was not changed.'
|
|
}
|
|
}
|
|
$arguments = @('/set', "/subcategory:{$($state.Guid)}")
|
|
if ($state.Mode -eq 'minimum') {
|
|
# Only enable required flags: never disable another writer's added flag.
|
|
if ($state.Mask -band 1) { $arguments += '/success:enable' }
|
|
if ($state.Mask -band 2) { $arguments += '/failure:enable' }
|
|
} else {
|
|
$success = if ($state.Mask -band 1) { 'enable' } else { 'disable' }
|
|
$failure = if ($state.Mask -band 2) { 'enable' } else { 'disable' }
|
|
$arguments += "/success:$success", "/failure:$failure"
|
|
}
|
|
if ($null -ne $state.IpsecObservations) {
|
|
# This check runs after the operator prompt and durable recovery journal.
|
|
$evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence)
|
|
Assert-WelaIpsecPrerequisite $evidence
|
|
}
|
|
Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments $arguments
|
|
}
|
|
Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy `
|
|
-Target @{ Guid = $guid } -Desired @{ Mask = $Mask; Mode = $Mode } -Read $read -Compliant $test -Apply $apply -CallbackState $state
|
|
}
|
|
|
|
function Set-WelaProfileAuditControls {
|
|
param($Context, $Plan, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite })
|
|
if ($Plan.PSObject.Properties['CustomProfileSource']) {
|
|
$Context | Add-Member NoteProperty CustomProfileGuard ([pscustomobject]@{Source=$Plan.CustomProfileSource;Role=$Plan.role;Build=$Plan.build}) -Force
|
|
Assert-WelaConfigurationProfileGuard $Context
|
|
}
|
|
# The caller must complete Assert-WelaAuditProfileTarget before any mutations.
|
|
$selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) })
|
|
if ($selected.Count -eq 0) { return }
|
|
Set-WelaAuditPrecedenceControl -Context $Context
|
|
$precedence = $Context.Results[$Context.Results.Count - 1]
|
|
foreach ($policy in $Plan.policies) {
|
|
if ($policy.mode -notin @('exact', 'minimum') -and -not ($policy.mode -eq 'optional' -and $Plan.includeOptional)) { continue }
|
|
if ($precedence.Status -eq 'Failed' -or ($precedence.Status -eq 'Skipped' -and -not $Context.DryRun)) {
|
|
$Context.Results.Add([pscustomobject]@{ Id = "AuditPolicy/$($policy.id)"; Kind = 'AuditPolicy'; Target = @{ Guid = $policy.guid }; Desired = $policy.requiredMask; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'Audit precedence was not verified; dependent policy was not changed.' })
|
|
continue
|
|
}
|
|
$conditional = Test-WelaIpsecConditionalPolicy $Plan $policy
|
|
$observations = $null; $blocked = $false
|
|
if ($conditional) {
|
|
$observations = New-Object 'System.Collections.Generic.List[object]'
|
|
try {
|
|
$evidence = & $ReadIpsec; $observations.Add($evidence)
|
|
$blocked = $evidence.Status -ne 'Applicable'
|
|
$status = if ($evidence.Status -eq 'NotObservedWithinScope') { 'Skipped' } else { 'Failed' }
|
|
$diagnostic = "IPsec prerequisite $($evidence.Status); policy preserved. $($evidence.Diagnostic)"
|
|
} catch { $blocked = $true; $status = 'Failed'; $diagnostic = $_.ToString() }
|
|
if ($blocked) {
|
|
$Context.Results.Add([pscustomobject]@{Id="AuditPolicy/$($policy.id)";Kind='AuditPolicy';Target=@{Guid=$policy.guid};Desired=@{Mask=$policy.requiredMask;Mode='exact'};Before=$null;After=$null;Status=$status;Diagnostic=$diagnostic})
|
|
}
|
|
}
|
|
if (-not $blocked) {
|
|
$mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' }
|
|
Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence -IpsecObservations $observations -ReadIpsec $ReadIpsec
|
|
}
|
|
$row = $Context.Results[$Context.Results.Count - 1]
|
|
if ($conditional) { $row | Add-Member NoteProperty PrerequisiteObservations $observations }
|
|
$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
|
|
$row | Add-Member NoteProperty Prerequisites $policy.prerequisites
|
|
$row | Add-Member NoteProperty Evidence $policy.evidence
|
|
$row | Add-Member NoteProperty SourceIds $policy.sourceIds
|
|
$row | Add-Member NoteProperty Note $policy.note
|
|
}
|
|
}
|
|
|
|
function Get-WelaAuditPrecedenceSource {
|
|
# Normalize each documented RSoP schema separately. Cached evidence does not
|
|
# prove the current registry writer, even when the represented value is known.
|
|
$targetKey = 'SYSTEM\CurrentControlSet\Control\Lsa'
|
|
$targetName = 'SCENoApplyLegacyAuditPolicy'
|
|
$matches = @()
|
|
foreach ($class in @('RSOP_RegistryPolicySetting', 'RSOP_SecuritySettingNumeric', 'RSOP_RegistryValue')) {
|
|
try {
|
|
$records = @(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName $class -ErrorAction Stop)
|
|
foreach ($record in $records) {
|
|
$key = ''; $name = ''; $raw = $null; $reported = $null; $known = $false
|
|
if ($class -eq 'RSOP_RegistryPolicySetting') {
|
|
if ($record.PSObject.Properties['deleted'] -and $record.deleted -eq $true) { continue }
|
|
if ($record.PSObject.Properties['registryKey']) { $key = [string]$record.registryKey }
|
|
if ($record.PSObject.Properties['valueName']) { $name = [string]$record.valueName }
|
|
if ($record.PSObject.Properties['value']) { $raw = $record.value }
|
|
# REG_DWORD is exactly four little-endian bytes. Other types,
|
|
# arrays and lengths remain unknown rather than being coerced.
|
|
if ($record.PSObject.Properties['valueType'] -and ($record.valueType -is [int] -or $record.valueType -is [uint32] -or $record.valueType -is [long]) -and $record.valueType -eq 4 -and $raw -is [byte[]] -and $raw.Length -eq 4) {
|
|
if ($raw[1] -eq 0 -and $raw[2] -eq 0 -and $raw[3] -eq 0 -and $raw[0] -in @(0, 1)) {
|
|
$reported = [uint32]$raw[0]; $known = $true
|
|
}
|
|
}
|
|
} elseif ($class -eq 'RSOP_SecuritySettingNumeric') {
|
|
if ($record.PSObject.Properties['KeyName']) { $key = [string]$record.KeyName }
|
|
if ($record.PSObject.Properties['Setting']) { $raw = $record.Setting }
|
|
if (($raw -is [int] -or $raw -is [uint32] -or $raw -is [long]) -and $raw -in @(0, 1)) {
|
|
$reported = [uint32]$raw; $known = $true
|
|
}
|
|
# This security schema identifies settings by name; accept the
|
|
# exact policy name as well as a matching full registry path.
|
|
if ($key -eq $targetName) { $key = "$targetKey\$targetName" }
|
|
} else {
|
|
if ($record.PSObject.Properties['Path']) { $key = [string]$record.Path }
|
|
if ($record.PSObject.Properties['Data']) { $raw = $record.Data }
|
|
# Security-option registry values expose Type/Data, not Value.
|
|
# Only canonical decimal strings 0/1 of REG_DWORD are decoded.
|
|
if ($record.PSObject.Properties['Type'] -and ($record.Type -is [int] -or $record.Type -is [uint32] -or $record.Type -is [long]) -and $record.Type -eq 4 -and $raw -is [string] -and $raw -cin @('0', '1')) {
|
|
$reported = [uint32]$raw; $known = $true
|
|
}
|
|
}
|
|
$key = $key -replace '^(MACHINE|HKEY_LOCAL_MACHINE|HKLM)\\', ''
|
|
$matchingTarget = if ($class -eq 'RSOP_RegistryPolicySetting') { $key -eq $targetKey -and $name -eq $targetName } else { $key -eq "$targetKey\$targetName" }
|
|
if (-not $matchingTarget) { continue }
|
|
$matches += [pscustomobject]@{
|
|
SourceClass = $class
|
|
GpoId = $(if ($record.PSObject.Properties['GPOID']) { $record.GPOID } else { $null })
|
|
Precedence = $(if ($record.PSObject.Properties['precedence']) { $record.precedence } else { [uint32]::MaxValue })
|
|
ReportedValue = $(if ($known) { $reported } else { $raw })
|
|
ValueRecognized = $known
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
$ordered = @($matches | Sort-Object Precedence)
|
|
$policy = $ordered | Select-Object -First 1
|
|
$gpo = if ($policy) { $policy.GpoId } else { $null }
|
|
$value = if ($policy) { $policy.ReportedValue } else { $null }
|
|
[pscustomobject]@{
|
|
GpoId = $gpo; ReportedValue = $value
|
|
SourceClass = $(if ($policy) { $policy.SourceClass } else { $null })
|
|
ConflictsWithRequiredValue = if ($policy -and $policy.ValueRecognized) { $value -ne 1 } else { $null }
|
|
Matches = $ordered
|
|
Description = if ($gpo) { "Last-applied RSoP GPO evidence: $gpo (may be stale; current registry writer unknown; see Matches for all observations)" } else { 'Unknown (no matching RSoP source; local, GPO or MDM ownership is not established)' }
|
|
}
|
|
}
|
|
|
|
function Get-WelaAuditPrecedenceState {
|
|
param([switch]$Offline)
|
|
$registry = $null; $status = 'Unknown'; $diagnostic = 'Offline plan; live precedence was not read.'
|
|
$source = $null
|
|
if (-not $Offline) {
|
|
$source = Get-WelaAuditPrecedenceSource
|
|
try {
|
|
$registry = Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy
|
|
$status = if (-not $registry.ValueExists) { 'Not configured' }
|
|
elseif ($registry.Type -ne 'DWord' -or $registry.Value -notin @(0, 1)) { 'Unknown' }
|
|
elseif ($registry.Value -eq 1) { 'Enabled' } else { 'Disabled' }
|
|
$diagnostic = 'Observed registry state only; effective audit masks are read separately. GPO/MDM can change this value after verification.'
|
|
} catch { $diagnostic = $_.ToString() }
|
|
}
|
|
[pscustomobject]@{ Name = 'SCENoApplyLegacyAuditPolicy'; RequiredValue = 1; RequiredType = 'DWord'; State = $status; Registry = $registry; PolicySource = $source; Diagnostic = $diagnostic }
|
|
}
|
|
|
|
function Set-WelaAuditPrecedenceControl {
|
|
param($Context)
|
|
Set-WelaRegistryControl -Context $Context -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1
|
|
$row = $Context.Results[$Context.Results.Count - 1]
|
|
$source = Get-WelaAuditPrecedenceSource
|
|
$row | Add-Member NoteProperty PolicySource $source
|
|
$row | Add-Member NoteProperty VerificationScope 'Current registry value and per-subcategory effective masks; no Group Policy refresh was performed.'
|
|
if ($source.ConflictsWithRequiredValue) {
|
|
$row.Diagnostic += ' Last-applied RSoP reports a different value; reconcile that GPO and verify again after policy refresh.'
|
|
Write-Host $row.Diagnostic -ForegroundColor DarkYellow
|
|
}
|
|
Write-Host "Audit precedence policy source: $($source.Description)"
|
|
}
|
|
|
|
function Set-WelaCertificateAuditControl {
|
|
param($Context)
|
|
# Existing configure prompt/Auto covers its historical set-and-restart action.
|
|
# The shared CA engine verifies identity and prerequisites before every write.
|
|
Invoke-WelaLegacyAdcsControl -Context $Context
|
|
}
|
|
|
|
function Set-WelaNtlmConfigurationControl {
|
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
|
param(
|
|
$Context,
|
|
[ValidateSet('Outgoing', 'Domain')][string]$Scope,
|
|
[ValidateSet('PreserveOrAudit', 'Audit', 'Deny')][string]$Mode = 'PreserveOrAudit'
|
|
)
|
|
$path = if ($Scope -eq 'Outgoing') { 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' } else { 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' }
|
|
$name = if ($Scope -eq 'Outgoing') { 'RestrictSendingNTLMTraffic' } else { 'AuditNTLMInDomain' }
|
|
$desired = if ($Scope -eq 'Domain') { 7 } elseif ($Mode -eq 'Deny') { 2 } else { 1 }
|
|
$id = "Registry/$path/$name"
|
|
$state = $null
|
|
$skipReason = ''
|
|
$status = 'Skipped'
|
|
try {
|
|
$state = if ($Scope -eq 'Outgoing') { Get-WelaOutgoingNtlmState } else { Get-WelaDomainNtlmState }
|
|
Write-Host "$Scope NTLM: $($state.Description)"
|
|
if ($Scope -eq 'Outgoing') { Write-Host "Policy source: $($state.PolicySource)" }
|
|
if ($Scope -eq 'Domain' -and -not $state.Applicable) {
|
|
if ($state.Description -notlike 'Not applicable*') { throw "Domain NTLM applicability is unknown: $($state.Description)" }
|
|
$skipReason = $state.Description
|
|
} elseif (-not $state.Readable) {
|
|
throw "$Scope NTLM current state could not be read: $($state.Description)"
|
|
} elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $state.Type -eq 'DWord' -and $state.Value -eq 2) {
|
|
$skipReason = 'Preserved existing Deny all enforcement (2); use -OutgoingNtlmMode Audit to explicitly replace it.'
|
|
} elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $null -ne $state.Type -and ($state.Type -ne 'DWord' -or $state.Value -notin @(0, 1, 2))) {
|
|
$skipReason = "Preserved unknown outgoing NTLM value/type ($($state.Value)/$($state.Type)); select an explicit mode after policy review."
|
|
}
|
|
if (-not $skipReason) {
|
|
if ($Scope -eq 'Outgoing' -and $Mode -eq 'Deny') {
|
|
Write-Warning 'Explicit Deny mode can break NTLM authentication. This is enforcement, not audit-only configuration.'
|
|
}
|
|
if (-not $PSCmdlet.ShouldProcess($id, "Set $Scope NTLM policy to $desired")) {
|
|
$skipReason = 'ShouldProcess declined the NTLM change.'
|
|
} else {
|
|
# Shared runner owns prompts, dry-run suppression, exact registry
|
|
# before-state journal, type/value read-back and final drift check.
|
|
$preserve = $null
|
|
if ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit') {
|
|
$preserve = {
|
|
param($snapshot)
|
|
if ($snapshot.ValueExists -and $snapshot.Type -eq 'DWord' -and $snapshot.Value -eq 2) {
|
|
return 'Preserved newly observed Deny all enforcement (2); explicit Audit mode is required to replace it.'
|
|
}
|
|
if ($snapshot.ValueExists -and ($snapshot.Type -ne 'DWord' -or $snapshot.Value -notin @(0, 1, 2))) {
|
|
return "Preserved newly observed unknown outgoing NTLM value/type ($($snapshot.Value)/$($snapshot.Type))."
|
|
}
|
|
}
|
|
}
|
|
Set-WelaRegistryControl -Context $Context -Path $path -Name $name -Value $desired -PreserveWhen $preserve
|
|
return
|
|
}
|
|
}
|
|
} catch {
|
|
$status = 'Failed'
|
|
$skipReason = $_.ToString()
|
|
}
|
|
$Context.Results.Add([pscustomobject][ordered]@{
|
|
Id = $id; Kind = 'Registry'; Target = @{ Path = $path; Name = $name }
|
|
Desired = @{ Value = $desired; Type = 'DWord' }; Before = $state; After = $state
|
|
Status = $status; Diagnostic = $skipReason
|
|
})
|
|
$color = if ($status -eq 'Failed') { 'Red' } else { 'Yellow' }
|
|
Write-Host "[$status] $id $skipReason" -ForegroundColor $color
|
|
}
|