Files
WELA/scripts/CustomAuditProfiles.ps1
T
田中ザック Isaac Mathis 83b2ddd526 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
2026-09-20 18:09:52 +09:00

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() }
}