mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 15:25:24 +02:00
Support validated custom audit profile files through the shared engine (#416)
* Support validated operator-owned advanced audit profile files * Reject lenient custom profile JSON and protect report output aliases * Link custom audit profile changelog to PR 416 * Make custom JSON rejection fixtures portable across PowerShell versions
This commit is contained in:
1 parent
4431533535
commit
83b2ddd526
14 files changed
+715
-9
No files matched your search
@@ -33,6 +33,16 @@ function New-WelaConfigurationContext {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -42,6 +52,7 @@ function Invoke-WelaConfigurationControl {
|
||||
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) {
|
||||
@@ -68,12 +79,15 @@ function Invoke-WelaConfigurationControl {
|
||||
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)"
|
||||
@@ -97,10 +111,15 @@ function Complete-WelaConfiguration {
|
||||
[ValidateSet("native-windows-configuration", "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", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-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'
|
||||
@@ -126,6 +145,7 @@ function Complete-WelaConfiguration {
|
||||
$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 }
|
||||
@@ -299,6 +319,10 @@ function Set-WelaAuditPolicyControl {
|
||||
|
||||
function Set-WelaProfileAuditControls {
|
||||
param($Context, $Plan)
|
||||
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 }
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Loaded inside AuditProfiles.psm1. Custom profiles are data, never scripts.
|
||||
function Assert-WelaCustomObject {
|
||||
param($Object,[string[]]$Allowed,[string[]]$Required=@())
|
||||
if ($Object -isnot [pscustomobject]) { throw 'Expected a custom-profile JSON object.' }
|
||||
$keys=@($Object.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
foreach ($key in $keys) { if ($key -cnotin $Allowed) { throw "Unknown custom-profile property: $key" } }
|
||||
foreach ($key in $Required) { if ($key -cnotin $keys) { throw "Missing custom-profile property: $key" } }
|
||||
}
|
||||
function Assert-WelaCustomText {
|
||||
param($Value,[string]$Field)
|
||||
if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value) -or $Value.Length -gt 2048) { throw "Invalid custom-profile text: $Field" }
|
||||
}
|
||||
function Assert-WelaCustomStringArray {
|
||||
param($Values,[string[]]$Allowed,[switch]$AllowEmpty)
|
||||
if ($Values -isnot [array] -or (-not $AllowEmpty -and $Values.Count -eq 0)) { throw 'Expected a nonempty custom-profile array.' }
|
||||
$seen=@{}
|
||||
foreach ($value in $Values) {
|
||||
if ($value -isnot [string] -or $value -cnotin $Allowed -or $seen.ContainsKey($value)) { throw "Unknown or duplicate custom-profile array value: $value" }
|
||||
$seen[$value]=$true
|
||||
}
|
||||
}
|
||||
function ConvertFrom-WelaCustomProfileJson {
|
||||
param([string]$Text)
|
||||
# ConvertFrom-Json accepts some JavaScript extensions (including single-quoted
|
||||
# and bare property names). Validate the entire JSON token stream first, so
|
||||
# those forms cannot bypass duplicate-property tracking below.
|
||||
$lexical=[regex]'\G(?:[ \t\r\n]+|"(?:\\["\\/bfnrt]|\\u[0-9A-Fa-f]{4}|[^"\\\x00-\x1f])*"|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?(?![A-Za-z0-9_.+-])|(?:true|false|null)(?![A-Za-z0-9_])|[{}\[\]:,])'
|
||||
$position=0
|
||||
while ($position -lt $Text.Length) {
|
||||
$match=$lexical.Match($Text,$position)
|
||||
if (-not $match.Success -or $match.Index -ne $position) { throw 'Custom profiles require strict JSON tokens; JavaScript extensions and invalid escapes are not supported.' }
|
||||
$position+=$match.Length
|
||||
}
|
||||
# Match JSON strings first; braces/property-looking text inside strings is inert.
|
||||
$withoutStrings=[regex]::Replace($Text,'"(?:\\.|[^"\\])*"','""')
|
||||
if ($withoutStrings -match '//|/\*|,\s*[}\]]') { throw 'Custom profiles require strict JSON without comments or trailing commas.' }
|
||||
$tokens=[regex]::Matches($Text,'"(?:\\.|[^"\\])*"|[{}\[\]:,]')
|
||||
$stack=New-Object 'System.Collections.Generic.Stack[object]'
|
||||
for ($i=0;$i -lt $tokens.Count;$i++) {
|
||||
$token=$tokens[$i].Value
|
||||
if ($token -eq '{') { $stack.Push(@{}) }
|
||||
elseif ($token -eq '[') { $stack.Push($null) }
|
||||
elseif ($token -in @('}',']')) { if (-not $stack.Count) { throw 'Unbalanced custom-profile JSON.' }; $null=$stack.Pop() }
|
||||
elseif ($token.StartsWith('"') -and $i+1 -lt $tokens.Count -and $tokens[$i+1].Value -eq ':') {
|
||||
if (-not $stack.Count -or $null -eq $stack.Peek()) { throw 'JSON property outside object.' }
|
||||
$holder=ConvertFrom-Json -InputObject ('{'+$token+':null}') -ErrorAction Stop
|
||||
$name=@($holder.PSObject.Properties.Name)[0]
|
||||
if ($stack.Peek().ContainsKey($name)) { throw "Duplicate or case-colliding custom-profile property: $name" }
|
||||
$stack.Peek()[$name]=$true
|
||||
}
|
||||
if ($stack.Count -gt 20) { throw 'Custom-profile nesting exceeds 20 levels.' }
|
||||
}
|
||||
ConvertFrom-Json -InputObject $Text -ErrorAction Stop
|
||||
}
|
||||
function Get-WelaCustomFileHash {
|
||||
param([byte[]]$Bytes)
|
||||
$algorithm=[Security.Cryptography.SHA256]::Create()
|
||||
try { ([BitConverter]::ToString($algorithm.ComputeHash($Bytes))).Replace('-','').ToLowerInvariant() } finally { $algorithm.Dispose() }
|
||||
}
|
||||
function Import-WelaCustomAuditProfiles {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
$ErrorActionPreference='Stop'
|
||||
$file=Get-Item -LiteralPath $Path -ErrorAction Stop
|
||||
if ($file -isnot [IO.FileInfo] -or $file.Length -lt 1 -or $file.Length -gt 1048576) { throw 'Custom profile must be a nonempty JSON file no larger than 1 MiB.' }
|
||||
$bytes=[IO.File]::ReadAllBytes($file.FullName)
|
||||
if ($bytes.Length -gt 1048576) { throw 'Custom profile grew beyond 1 MiB.' }
|
||||
$utf8=New-Object Text.UTF8Encoding($false,$true)
|
||||
$data=ConvertFrom-WelaCustomProfileJson ($utf8.GetString($bytes).TrimStart([char]0xFEFF))
|
||||
Assert-WelaCustomObject $data @('schemaVersion','kind','catalog','sources','profiles') @('schemaVersion','kind','catalog','sources','profiles')
|
||||
if (($data.schemaVersion -isnot [int] -and $data.schemaVersion -isnot [long]) -or $data.schemaVersion -ne 1 -or $data.kind -cne 'WelaCustomAuditProfiles') { throw 'Unsupported custom-profile kind/schema version.' }
|
||||
$canonicalPath=Join-Path $PSScriptRoot '../config/audit_profiles.json'
|
||||
$canonicalHash=(Get-FileHash -LiteralPath $canonicalPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
$builtin=Import-WelaAuditProfiles
|
||||
if ((Get-FileHash -LiteralPath $canonicalPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $canonicalHash) { throw 'Canonical profile catalog changed during validation.' }
|
||||
$canonical=@{}; foreach ($entry in $builtin.catalog) { $canonical[$entry.id]=$entry }
|
||||
if ($data.catalog -isnot [array] -or $data.catalog.Count -lt 1 -or $data.catalog.Count -gt 59) { throw 'Declare 1..59 canonical custom catalog references.' }
|
||||
$declared=@{}; $guids=@{}
|
||||
foreach ($entry in $data.catalog) {
|
||||
Assert-WelaCustomObject $entry @('id','guid','category') @('id','guid','category')
|
||||
Assert-WelaCustomText $entry.id 'catalog id'
|
||||
if (-not $canonical.ContainsKey($entry.id) -or $canonical[$entry.id].id -cne $entry.id -or $entry.guid -isnot [string] -or
|
||||
$entry.guid -ine $canonical[$entry.id].guid -or $entry.category -cne $canonical[$entry.id].category) { throw "Canonical name/GUID/category mismatch: $($entry.id)" }
|
||||
if ($declared.ContainsKey($entry.id) -or $guids.ContainsKey($entry.guid)) { throw 'Duplicate custom catalog name/GUID.' }
|
||||
$declared[$entry.id]=$true; $guids[$entry.guid]=$true
|
||||
}
|
||||
if ($data.sources -isnot [pscustomobject] -or @($data.sources.PSObject.Properties).Count -eq 0) { throw 'Custom sources are required.' }
|
||||
$sourceIds=@($data.sources.PSObject.Properties.Name)
|
||||
foreach ($source in $data.sources.PSObject.Properties) {
|
||||
if ($source.Name -cnotmatch '\A[a-z][a-z0-9-]{0,63}\z') { throw 'Invalid custom source id.' }
|
||||
Assert-WelaCustomObject $source.Value @('title','version','url') @('title','version','url')
|
||||
foreach ($field in @('title','version','url')) { Assert-WelaCustomText $source.Value.$field $field }
|
||||
$uri=$null
|
||||
if (-not [Uri]::TryCreate($source.Value.url,[UriKind]::Absolute,[ref]$uri) -or $uri.Scheme -ne 'https') { throw 'Source URL must be an absolute HTTPS reference; it is not fetched.' }
|
||||
}
|
||||
if ($data.profiles -isnot [array] -or $data.profiles.Count -lt 1 -or $data.profiles.Count -gt 128) { throw 'Custom file requires 1..128 profiles.' }
|
||||
$ids=@{}; $roles=@('Client','MemberServer','DomainController','ADCS')
|
||||
foreach ($profile in $data.profiles) {
|
||||
Assert-WelaCustomObject $profile @('id','version','sourceIds','omitted','scope','appliesTo','controls','roleOverrides','note','referenceOnly') @('id','version','sourceIds','omitted','scope','appliesTo','controls','roleOverrides')
|
||||
Assert-WelaCustomText $profile.id 'profile id'; Assert-WelaCustomText $profile.version 'profile version'
|
||||
if ($profile.id -cnotmatch '\A[a-z][a-z0-9-]{0,127}\z' -or $ids.ContainsKey($profile.id) -or $profile.id -in @($builtin.profiles.id)) { throw 'Duplicate, invalid or built-in custom profile id.' }
|
||||
$ids[$profile.id]=$true
|
||||
if ($profile.scope -cne 'advanced-audit-policy-only' -or $profile.omitted -cne 'unchanged') { throw 'Custom scope must be advanced-audit-policy-only with omitted unchanged.' }
|
||||
if ($profile.PSObject.Properties['referenceOnly'] -and $profile.referenceOnly -isnot [bool]) { throw 'referenceOnly must be boolean.' }
|
||||
if ($profile.PSObject.Properties['note'] -and $profile.note -isnot [string]) { throw 'Profile note must be text.' }
|
||||
Assert-WelaCustomStringArray $profile.sourceIds $sourceIds
|
||||
if ($profile.appliesTo -isnot [array] -or -not $profile.appliesTo.Count) { throw 'Custom profile applicability array is required.' }
|
||||
foreach ($range in $profile.appliesTo) {
|
||||
Assert-WelaCustomObject $range @('roles','minBuild','maxBuild') @('roles','minBuild','maxBuild')
|
||||
Assert-WelaCustomStringArray $range.roles $roles
|
||||
foreach ($field in @('minBuild','maxBuild')) { if (($range.$field -isnot [int] -and $range.$field -isnot [long]) -or $range.$field -lt 1 -or $range.$field -gt 999999) { throw 'Custom build bounds must be integers in 1..999999.' } }
|
||||
if ($range.maxBuild -lt $range.minBuild) { throw 'Reversed custom build range.' }
|
||||
}
|
||||
Assert-WelaCustomObject $profile.roleOverrides $roles
|
||||
$sets=@($profile.controls)+@($profile.roleOverrides.PSObject.Properties | ForEach-Object { $_.Value })
|
||||
foreach ($set in $sets) {
|
||||
Assert-WelaCustomObject $set @($declared.Keys)
|
||||
foreach ($control in $set.PSObject.Properties) {
|
||||
$value=$control.Value
|
||||
Assert-WelaCustomObject $value @('mode','mask','note','evidence','sourceIds') @('mode')
|
||||
if ($value.mode -cnotin @('exact','minimum','optional','unchanged','not-configured','not-applicable')) { throw 'Invalid custom policy mode.' }
|
||||
$hasMask=$null -ne $value.PSObject.Properties['mask']
|
||||
if ($value.mode -in @('exact','minimum','optional')) {
|
||||
if (-not $hasMask -or ($value.mask -isnot [int] -and $value.mask -isnot [long]) -or $value.mask -notin @(0,1,2,3)) { throw 'Custom audit mask must be an integer 0..3.' }
|
||||
} elseif ($hasMask) { throw 'Preserve/non-applicable modes must not specify a mask.' }
|
||||
if ($value.PSObject.Properties['sourceIds']) { Assert-WelaCustomStringArray $value.sourceIds $sourceIds }
|
||||
foreach ($field in @('note','evidence')) { if ($value.PSObject.Properties[$field] -and $value.$field -isnot [string]) { throw 'Control note/evidence must be text.' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
# Roles and prerequisites come only from the authoritative bundled catalog.
|
||||
$data.catalog=$builtin.catalog
|
||||
$source=[pscustomobject]@{Path=$file.FullName;Sha256=(Get-WelaCustomFileHash $bytes);CanonicalPath=[IO.Path]::GetFullPath($canonicalPath);CanonicalSha256=$canonicalHash;Kind='OperatorCustomFile';Provenance='Operator-declared policy; not a Microsoft/CIS/ASD endorsement.'}
|
||||
$data | Add-Member NoteProperty customSource $source
|
||||
Assert-WelaCustomProfileSource $source
|
||||
return $data
|
||||
}
|
||||
function Assert-WelaCustomProfileSource {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)]$Source)
|
||||
foreach ($entry in @(@($Source.Path,$Source.Sha256),@($Source.CanonicalPath,$Source.CanonicalSha256))) {
|
||||
if ((Get-FileHash -LiteralPath $entry[0] -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $entry[1]) { throw 'Custom profile or canonical catalog changed since validation; no further configuration is authorized by this plan.' }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WelaCustomReportPath {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
$provider=$null;$drive=$null
|
||||
$full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive)
|
||||
if ($provider.Name -ne 'FileSystem' -or $full -match '^[\\/]{2}') { throw 'Custom profile reports require a local filesystem path.' }
|
||||
# New output files also prevent hard-link aliases from overwriting a source or
|
||||
# the canonical catalog. A final CreateNew open closes the file-existence race.
|
||||
if (Test-Path -LiteralPath $full -ErrorAction Stop) { throw 'Custom profile report output already exists; select a new file to preserve inputs and prior evidence.' }
|
||||
$parent=[IO.DirectoryInfo]([IO.Path]::GetDirectoryName($full))
|
||||
if (-not $parent.Exists) { throw 'Custom profile report parent directory must exist.' }
|
||||
while ($parent) {
|
||||
if ($parent.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Custom profile reports cannot traverse symlink or reparse-point directories.' }
|
||||
$parent=$parent.Parent
|
||||
}
|
||||
return $full
|
||||
}
|
||||
|
||||
function Write-WelaCustomProfileReport {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)]$Report,[Parameter(Mandatory)][string]$Path)
|
||||
$full=Get-WelaCustomReportPath $Path
|
||||
$text=$Report | ConvertTo-Json -Depth 20
|
||||
$bytes=(New-Object Text.UTF8Encoding($false)).GetBytes($text)
|
||||
$stream=[IO.File]::Open($full,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
||||
try { $stream.Write($bytes,0,$bytes.Length);$stream.Flush($true) } finally { $stream.Dispose() }
|
||||
}
|
||||
Reference in new issue
Block a user