Private/Exceptions.ps1
|
function Read-HLExceptionFile { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) $resolvedPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path $document = Get-Content -LiteralPath $resolvedPath -Raw -ErrorAction Stop | ConvertFrom-Json return [pscustomobject][ordered]@{ Path = $resolvedPath Document = $document Exceptions = @($document.exceptions) } } function Test-HLDateString { [CmdletBinding()] param( [AllowNull()] [object]$Value, [Parameter(Mandatory)] [ref]$ParsedDate ) $candidate = [datetime]::MinValue $valid = [datetime]::TryParseExact( [string]$Value, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$candidate ) if ($valid) { $ParsedDate.Value = $candidate } return $valid } function Test-HLExceptionDocument { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Document ) $errors = New-Object System.Collections.Generic.List[string] $warnings = New-Object System.Collections.Generic.List[string] $catalogIds = @((Get-HLControlCatalog).controls | ForEach-Object { [string]$_.id }) if (-not (Test-HLProperty -InputObject $Document -Name 'schemaVersion') -or [string]$Document.schemaVersion -ne '1.0') { $errors.Add("schemaVersion must be '1.0'.") } if (-not (Test-HLProperty -InputObject $Document -Name 'exceptions')) { $errors.Add('The document must contain an exceptions array.') return [pscustomobject][ordered]@{ IsValid = $false; Errors = $errors.ToArray(); Warnings = $warnings.ToArray(); ExceptionCount = 0 } } $seen = @{} $index = 0 foreach ($exception in @($Document.exceptions)) { $index++ $prefix = "Exception #$index" foreach ($required in @('id', 'controlId', 'status', 'owner', 'reason', 'ticket', 'expires', 'targets')) { if (-not (Test-HLProperty -InputObject $exception -Name $required) -or $null -eq $exception.$required) { $errors.Add("$prefix is missing required property '$required'.") } } if (Test-HLProperty -InputObject $exception -Name 'id') { $id = [string]$exception.id if ([string]::IsNullOrWhiteSpace($id)) { $errors.Add("$prefix has an empty id.") } elseif ($id -notmatch '^EXC-[A-Za-z0-9._-]+$') { $errors.Add("Exception id '$id' does not match EXC-<identifier>.") } elseif ($seen.ContainsKey($id)) { $errors.Add("Duplicate exception id '$id'.") } else { $seen[$id] = $true } } if (Test-HLProperty -InputObject $exception -Name 'controlId') { $controlId = [string]$exception.controlId if ($controlId -notin $catalogIds) { $errors.Add("$prefix references unknown control '$controlId'.") } } $status = if (Test-HLProperty -InputObject $exception -Name 'status') { [string]$exception.status } else { '' } if ($status -notin @('Draft', 'Approved', 'Revoked')) { $errors.Add("$prefix has unsupported status '$status'.") } foreach ($propertyName in @('owner', 'ticket')) { if ((Test-HLProperty -InputObject $exception -Name $propertyName) -and [string]::IsNullOrWhiteSpace([string]$exception.$propertyName)) { $errors.Add("$prefix property '$propertyName' must not be empty.") } } if ((Test-HLProperty -InputObject $exception -Name 'reason') -and ([string]$exception.reason).Trim().Length -lt 10) { $errors.Add("$prefix reason must contain at least 10 characters.") } if (Test-HLProperty -InputObject $exception -Name 'targets') { $targets = @($exception.targets) if ($targets.Count -eq 0) { $errors.Add("$prefix must contain at least one target pattern.") } elseif (@($targets | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) }).Count -gt 0) { $errors.Add("$prefix contains an empty target pattern.") } if ('*' -in @($targets | ForEach-Object { [string]$_ })) { $warnings.Add("$prefix applies to every host. Prefer a narrower target pattern when operationally possible.") } } if ((Test-HLProperty -InputObject $exception -Name 'baselines') -and @($exception.baselines | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) }).Count -gt 0) { $errors.Add("$prefix contains an empty baseline name.") } $expiry = [datetime]::MinValue $expiryValid = $false if (Test-HLProperty -InputObject $exception -Name 'expires') { $expiryValid = Test-HLDateString -Value $exception.expires -ParsedDate ([ref]$expiry) if (-not $expiryValid) { $errors.Add("$prefix expires must use yyyy-MM-dd.") } elseif ($status -eq 'Approved' -and $expiry.Date -lt (Get-Date).Date) { $warnings.Add("$prefix is approved but expired on $($expiry.ToString('yyyy-MM-dd')). It will not be applied.") } } if ($status -eq 'Approved') { if (-not (Test-HLProperty -InputObject $exception -Name 'approvedBy') -or [string]::IsNullOrWhiteSpace([string]$exception.approvedBy)) { $errors.Add("$prefix is Approved but approvedBy is missing.") } $approvedOn = [datetime]::MinValue $approvedOnValid = $false if (-not (Test-HLProperty -InputObject $exception -Name 'approvedOn') -or [string]::IsNullOrWhiteSpace([string]$exception.approvedOn)) { $errors.Add("$prefix is Approved but approvedOn is missing.") } else { $approvedOnValid = Test-HLDateString -Value $exception.approvedOn -ParsedDate ([ref]$approvedOn) if (-not $approvedOnValid) { $errors.Add("$prefix approvedOn must use yyyy-MM-dd.") } elseif ($approvedOn.Date -gt (Get-Date).Date) { $errors.Add("$prefix approvedOn cannot be in the future.") } } if (-not (Test-HLProperty -InputObject $exception -Name 'compensatingControls') -or @($exception.compensatingControls).Count -eq 0) { $errors.Add("$prefix is Approved but compensatingControls is empty.") } elseif (@($exception.compensatingControls | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) }).Count -gt 0) { $errors.Add("$prefix contains an empty compensating control.") } if ($expiryValid -and $approvedOnValid -and $approvedOn.Date -gt $expiry.Date) { $errors.Add("$prefix approvedOn occurs after expires.") } } } return [pscustomobject][ordered]@{ IsValid = $errors.Count -eq 0 Errors = $errors.ToArray() Warnings = $warnings.ToArray() ExceptionCount = @($Document.exceptions).Count } } function Get-HLApplicableException { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$Exceptions, [Parameter(Mandatory)] [string]$ControlId, [Parameter(Mandatory)] [string]$ComputerName, [Parameter(Mandatory)] [string]$BaselineName ) foreach ($exception in @($Exceptions)) { if ([string]$exception.status -ne 'Approved') { continue } if ([string]$exception.controlId -ne $ControlId) { continue } $expiry = [datetime]::MinValue if (-not (Test-HLDateString -Value $exception.expires -ParsedDate ([ref]$expiry))) { continue } if ($expiry.Date -lt (Get-Date).Date) { continue } $targetMatch = $false foreach ($target in @($exception.targets)) { if ($ComputerName -like [string]$target) { $targetMatch = $true break } } if (-not $targetMatch) { continue } if ((Test-HLProperty -InputObject $exception -Name 'baselines') -and @($exception.baselines).Count -gt 0) { if ($BaselineName -notin @($exception.baselines | ForEach-Object { [string]$_ })) { continue } } return $exception } return $null } |