mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-02 04:24:44 +02:00
* 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
173 lines
13 KiB
PowerShell
173 lines
13 KiB
PowerShell
# 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() }
|
|
}
|