Files
WELA/modules/AuditProfiles.psm1
T
田中ザック Isaac Mathis b7e649185b Gate conditional IPsec auditing on native prerequisite evidence (#439)
* Gate conditional stronger-profile IPsec auditing on native evidence

* Use supported literal shells in native prerequisite matrix

* Retain native IPsec fixture diagnostics and allow inactive rule omission

* Expose exact native rule fields when prerequisite classification fails

* Recognize native inactive IPsec rules without granting applicability

* Restore standalone regression loading and valid owned IPsec auth defaults
2026-09-21 17:42:53 +09:00

354 lines
22 KiB
PowerShell

# Requires Windows PowerShell 5.1 or PowerShell 7. No Windows dependency for schema/planning.
Set-StrictMode -Version 2.0
. (Join-Path $PSScriptRoot '../scripts/CustomAuditProfiles.ps1')
. (Join-Path $PSScriptRoot '../scripts/IpsecPrerequisites.ps1')
function Get-WelaProperty {
param($Object, [string]$Name, $Default = $null)
if ($null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name]) { return $Object.$Name }
return $Default
}
function Import-WelaAuditProfiles {
[CmdletBinding()]
param([string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json'))
$data = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($data.schemaVersion -ne 1) { throw 'Unsupported audit profile schema version.' }
$roles = @('Client', 'MemberServer', 'DomainController', 'ADCS')
$ids = @{}; $guids = @{}; $profileIds = @{}
foreach ($policy in $data.catalog) {
if (-not $policy.id -or $ids.ContainsKey($policy.id)) { throw "Duplicate or empty policy id: $($policy.id)" }
if ($policy.guid -notmatch '^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$' -or $guids.ContainsKey($policy.guid)) { throw "Invalid or duplicate GUID: $($policy.guid)" }
if (@($policy.roles).Count -eq 0 -or @($policy.roles | Where-Object { $_ -notin $roles }).Count) { throw "Invalid policy roles: $($policy.id)" }
$ids[$policy.id] = $true; $guids[$policy.guid] = $true
}
foreach ($profile in $data.profiles) {
if (-not $profile.id -or $profileIds.ContainsKey($profile.id)) { throw "Duplicate or empty profile id: $($profile.id)" }
$profileIds[$profile.id] = $true
if ($profile.omitted -ne 'unchanged' -or $profile.scope -ne 'advanced-audit-policy-only' -or -not $profile.version) { throw "Invalid profile metadata: $($profile.id)" }
if (@($profile.sourceIds).Count -eq 0) { throw "Missing profile provenance: $($profile.id)" }
foreach ($source in $profile.sourceIds) {
if (-not $data.sources.PSObject.Properties[$source]) { throw "Unknown profile source: $source" }
}
if (@($profile.appliesTo).Count -eq 0) { throw "Missing applicability: $($profile.id)" }
foreach ($range in $profile.appliesTo) {
if (@($range.roles).Count -eq 0 -or @($range.roles | Where-Object { $_ -notin $roles }).Count -or $range.minBuild -lt 0 -or $range.maxBuild -lt $range.minBuild) { throw "Invalid applicability: $($profile.id)" }
}
$sets = @($profile.controls)
foreach ($override in $profile.roleOverrides.PSObject.Properties) {
if ($override.Name -notin $roles) { throw "Unknown role override: $($override.Name)" }
$sets += $override.Value
}
foreach ($set in $sets) {
foreach ($property in $set.PSObject.Properties) {
if (-not $ids.ContainsKey($property.Name)) { throw "Unknown audit policy: $($property.Name)" }
$control = $property.Value
foreach ($sourceId in @(Get-WelaProperty $control 'sourceIds' @())) {
if (-not $data.sources.PSObject.Properties[$sourceId]) { throw "Unknown control source: $sourceId" }
}
if ($control.mode -notin @('exact', 'minimum', 'unchanged', 'not-configured', 'optional', 'not-applicable')) { throw "Invalid mode: $($control.mode)" }
$hasMask = $null -ne $control.PSObject.Properties['mask']
if ($control.mode -in @('exact', 'minimum', 'optional')) {
if (-not $hasMask -or $control.mask -isnot [ValueType] -or $control.mask -is [bool] -or $control.mask -notin @(0, 1, 2, 3) -or [double]$control.mask -ne [int]$control.mask) { throw "Invalid mask: $($property.Name)" }
} elseif ($hasMask) { throw "Non-setting mode cannot have a mask: $($property.Name)" }
}
}
}
return $data
}
function Format-WelaAuditMask {
param($Mask)
if ($null -eq $Mask) { return 'Unknown' }
switch ([int]$Mask) { 0 { 'No Auditing' } 1 { 'Success' } 2 { 'Failure' } 3 { 'Success and Failure' } default { throw "Invalid mask: $Mask" } }
}
function Get-WelaAuditProfilePlan {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Profile,
[Parameter(Mandatory)][ValidateSet('Client', 'MemberServer', 'DomainController', 'ADCS')][string]$Role,
[Parameter(Mandatory)][ValidateRange(1, 999999)][int]$Build,
[hashtable]$Current = @{}, [switch]$IncludeOptional, [switch]$ObserveIpsec,
[scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite },
[string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json'),
[switch]$CustomFile
)
$data = if ($CustomFile) { Import-WelaCustomAuditProfiles -Path $Path } else { Import-WelaAuditProfiles -Path $Path }
$selected = @($data.profiles | Where-Object { $_.id -eq $Profile })
if ($selected.Count -ne 1) { throw "Unknown audit profile '$Profile'. Use -Cmd profiles to list profiles." }
$selected = $selected[0]
$matches = @($selected.appliesTo | Where-Object { $Role -in $_.roles -and $Build -ge $_.minBuild -and $Build -le $_.maxBuild })
if ($matches.Count -eq 0) { throw "Profile '$Profile' does not support role '$Role', build '$Build'." }
foreach ($value in $Current.Values) {
if ($null -ne $value -and ($value -is [bool] -or $value -notin @(0, 1, 2, 3))) { throw "Invalid effective audit mask: $value" }
}
$controls = @{}
foreach ($property in $selected.controls.PSObject.Properties) { $controls[$property.Name] = $property.Value }
$override = Get-WelaProperty $selected.roleOverrides $Role
if ($override) { foreach ($property in $override.PSObject.Properties) { $controls[$property.Name] = $property.Value } }
$ipsec = $null
if (-not $CustomFile -and $selected.id -ceq 'microsoft-stronger-reviewed-2026-09') {
$ipsec = if ($ObserveIpsec) { & $ReadIpsec } else { Get-WelaIpsecPrerequisite -Offline }
}
$rows = foreach ($policy in $data.catalog) {
$control = $controls[$policy.id]
$mode = if ($control) { $control.mode } else { 'unchanged' }
if ($Role -notin $policy.roles) { $mode = 'not-applicable' }
$mask = Get-WelaProperty $control 'mask'
$currentMask = if ($Current.ContainsKey($policy.guid)) { $Current[$policy.guid] } else { $null }
$desired = $null; $action = 'Preserve'; $compliance = 'Not assessed'
if ($mode -eq 'not-applicable') { $action = 'Not applicable'; $mask = $null }
elseif ($mode -eq 'optional' -and -not $IncludeOptional) { $action = 'Optional (not selected)' }
elseif ($mode -in @('exact', 'minimum', 'optional')) {
if ($null -eq $currentMask) { $action = 'Unknown'; $compliance = 'Unknown' }
else {
$desired = if ($mode -eq 'minimum') { [int]$currentMask -bor [int]$mask } else { [int]$mask }
$action = if ($currentMask -eq $desired) { 'No change' } else { 'Set' }
$compliance = if ($action -eq 'No change') { 'Compliant' } else { 'Drift' }
}
}
$conditional = $null
if ($ipsec -and $policy.id -eq 'IPsec Main Mode' -and $mode -eq 'optional') {
$conditional = $ipsec
if ($IncludeOptional -and $ipsec.Status -ne 'Applicable') {
$desired = $null
$action = if ($ipsec.Status -eq 'NotObservedWithinScope') { 'Preserve (IPsec not observed in scope)' } else { 'Unknown IPsec prerequisite' }
$compliance = 'Not assessed'
}
}
[pscustomobject][ordered]@{
conditionalPrerequisite = $conditional
id = $policy.id; guid = $policy.guid; category = $policy.category; mode = $mode
requiredMask = $mask; currentMask = $currentMask; targetMask = $desired
recommendation = if ($mode -in @('exact', 'minimum', 'optional')) { "$(Format-WelaAuditMask $mask) [$mode]" } else { $mode }
action = $action; compliance = $compliance; prerequisites = $policy.prerequisites
note = Get-WelaProperty $control 'note' ''; evidence = Get-WelaProperty $control 'evidence' ''
sourceIds = @(@($selected.sourceIds) + @(Get-WelaProperty $control 'sourceIds' @()) | Select-Object -Unique)
}
}
$sourceIds = @($rows | ForEach-Object { $_.sourceIds } | Select-Object -Unique)
$sources = foreach ($id in $sourceIds) { [pscustomobject]@{ id = $id; source = $data.sources.$id } }
$plan = [pscustomobject][ordered]@{
schemaVersion = 1; profile = $selected.id; version = $selected.version
scope = $selected.scope; role = $Role; build = $Build; includeOptional = [bool]$IncludeOptional
referenceOnly = [bool](Get-WelaProperty $selected 'referenceOnly' $false)
generatedUtc = [DateTime]::UtcNow.ToString('o'); schemaSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
note = Get-WelaProperty $selected 'note' ''; provenance = @($sources); policies = @($rows)
}
if ($CustomFile) {
$plan.schemaSha256 = $data.customSource.Sha256
$plan | Add-Member NoteProperty CustomProfileSource $data.customSource
Assert-WelaCustomProfileSource $data.customSource
}
return $plan
}
function Get-WelaEffectiveAuditPolicy {
[CmdletBinding()]
param()
# auditpol /get /r has localized text and no numeric mask column. Query the native API instead.
if (-not ('Wela.AuditProfiles.NativePolicy' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace Wela.AuditProfiles {
public static class NativePolicy {
[StructLayout(LayoutKind.Sequential)]
private struct PolicyInformation {
public Guid Subcategory;
public UInt32 Information;
public Guid Category;
}
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.U1)]
private static extern bool AuditQuerySystemPolicy(
[In, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] Guid[] subcategories,
UInt32 count, out IntPtr information);
[DllImport("advapi32.dll")]
private static extern void AuditFree(IntPtr buffer);
public static Dictionary<string, int> Read(Guid[] subcategories) {
IntPtr buffer = IntPtr.Zero;
try {
if (!AuditQuerySystemPolicy(subcategories, (UInt32)subcategories.Length, out buffer))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AuditQuerySystemPolicy failed");
if (buffer == IntPtr.Zero) throw new InvalidOperationException("Audit policy API returned a null buffer.");
int size = Marshal.SizeOf(typeof(PolicyInformation));
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < subcategories.Length; i++) {
var policy = (PolicyInformation)Marshal.PtrToStructure(IntPtr.Add(buffer, i * size), typeof(PolicyInformation));
// POLICY_AUDIT_EVENT_NONE = 4; success/failure are bits 1 and 2.
if (policy.Information > 4U) throw new InvalidOperationException("Unrecognized native audit flags.");
result.Add(policy.Subcategory.ToString().ToUpperInvariant(), (int)(policy.Information & 3U));
}
return result;
} finally { if (buffer != IntPtr.Zero) AuditFree(buffer); }
}
}
}
'@ -ErrorAction Stop
}
$catalog = (Import-WelaAuditProfiles).catalog
[guid[]]$guids = @($catalog | ForEach-Object { [guid]$_.guid })
$native = [Wela.AuditProfiles.NativePolicy]::Read($guids)
$current = @{}
foreach ($policy in $catalog) {
if (-not $native.ContainsKey($policy.guid)) { throw "Audit policy API omitted $($policy.id)." }
$current[$policy.guid] = $native[$policy.guid]
}
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,
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact'
)
if ($Mode -eq 'minimum' -and $Mask -eq 0) { return }
$arguments = @(Get-WelaAuditSetArguments -Guid $Guid -Mask $Mask -Mode $Mode)
$command = Get-Command -Name 'auditpol.exe' -CommandType Application -ErrorAction Stop
# Native stderr alone is not failure, including under Windows PowerShell 5.1.
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
$global:LASTEXITCODE = $null
$output = @(& $command.Source @arguments 2>&1)
$exitCode = $global:LASTEXITCODE # Snapshot before formatting diagnostics or running another command.
if ($null -eq $exitCode -or $exitCode -ne 0) {
throw "auditpol /set failed ($exitCode): $($output -join ' ')"
}
}
function Get-WelaHostContext {
[CmdletBinding()]
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 ($hasCA) { 'ADCS' }
else { 'MemberServer' }
[pscustomobject]@{ Role = $role; Build = [int]$os.BuildNumber }
}
function Assert-WelaAuditProfileTarget {
[CmdletBinding()]
param([Parameter(Mandatory)]$Plan, [Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$Current)
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
if ($Plan.referenceOnly) { throw 'Windows defaults are a reference, not an apply/restore profile.' }
if ($Context.Role -ne $Plan.role -or $Context.Build -ne $Plan.build) { throw 'Plan role/build does not match the actual Windows host.' }
if ($Current -isnot [hashtable]) { throw 'Effective policy reader did not return a GUID-to-mask map.' }
$selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) })
foreach ($policy in $selected) {
if (-not $Current.ContainsKey($policy.guid) -or $null -eq $Current[$policy.guid] -or $Current[$policy.guid] -notin @(0, 1, 2, 3)) { throw "Cannot apply with unknown current policy: $($policy.id). No policies changed." }
}
}
function Invoke-WelaAuditProfilePlan {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]$Plan,
[scriptblock]$ReadPolicy = { Get-WelaEffectiveAuditPolicy },
[scriptblock]$WritePolicy,
[scriptblock]$ReadContext = { Get-WelaHostContext },
[scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite }
)
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
$hostContext = & $ReadContext
$before = & $ReadPolicy
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 = $null; $effective = $null; $target = $null; $errorText = $null; $status = 'No change'
$conditional = Test-WelaIpsecConditionalPolicy $Plan $policy; $observations = @(); $skipConditional = $false
try {
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
if ($conditional) {
$evidence = & $ReadIpsec; $observations += $evidence
if ($evidence.Status -eq 'NotObservedWithinScope') { $status = 'Skipped'; $skipConditional = $true; $errorText = 'IPsec prerequisite not observed within the documented native scope; policy preserved.' }
else { Assert-WelaIpsecPrerequisite $evidence }
}
# 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 (-not $skipConditional -and $initial -ne $target) {
if ($PSCmdlet.ShouldProcess($policy.id, "Set audit policy to $(Format-WelaAuditMask $target)")) {
if ($Plan.PSObject.Properties['CustomProfileSource']) {
Assert-WelaCustomProfileSource $Plan.CustomProfileSource
$freshContext = & $ReadContext
if ($freshContext.Role -ne $Plan.role -or $freshContext.Build -ne $Plan.build) { throw 'Custom profile target changed before application.' }
}
if ($conditional) { $evidence = & $ReadIpsec; $observations += $evidence; Assert-WelaIpsecPrerequisite $evidence }
$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
if ($Plan.PSObject.Properties['CustomProfileSource']) { Assert-WelaCustomProfileSource $Plan.CustomProfileSource }
$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'
} 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
prerequisiteObservations = $observations
prerequisites = $policy.prerequisites; evidence = $policy.evidence; sourceIds = @($policy.sourceIds)
}
}
[pscustomobject]@{
profile = $Plan.profile; version = $Plan.version; scope = $Plan.scope; role = $Plan.role; build = $Plan.build
schemaSha256 = $Plan.schemaSha256; provenance = $Plan.provenance
success = (@($results | Where-Object { $_.status -eq 'Failed' }).Count -eq 0)
results = @($results)
}
}
Export-ModuleMember -Function Get-WelaIpsecPrerequisite, Assert-WelaIpsecPrerequisite, Test-WelaIpsecConditionalPolicy, Import-WelaAuditProfiles, Import-WelaCustomAuditProfiles, Assert-WelaCustomProfileSource, Get-WelaCustomReportPath, Write-WelaCustomProfileReport, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan