Private/Common.ps1

function Test-HLProperty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object]$InputObject,

        [Parameter(Mandatory)]
        [string]$Name
    )

    if ($null -eq $InputObject) {
        return $false
    }

    return $null -ne $InputObject.PSObject.Properties[$Name]
}

function Copy-HLObject {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$InputObject
    )

    return ($InputObject | ConvertTo-Json -Depth 40 | ConvertFrom-Json)
}

function Test-HLIsWindows {
    [CmdletBinding()]
    param()

    return [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
}

function Assert-HLWindows {
    [CmdletBinding()]
    param()

    if (-not (Test-HLIsWindows)) {
        throw 'Live Hardening Lens collection requires Windows. Catalog, baseline, report, and comparison commands remain cross-platform.'
    }
}

function Test-HLIsElevated {
    [CmdletBinding()]
    param()

    if (-not (Test-HLIsWindows)) {
        return $false
    }

    try {
        $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
        $principal = New-Object Security.Principal.WindowsPrincipal($identity)
        return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    }
    catch {
        return $false
    }
}

function Get-HLModuleVersion {
    [CmdletBinding()]
    param()

    $manifestPath = Join-Path -Path $script:HLModuleRoot -ChildPath 'HardeningLens.psd1'
    try {
        $manifest = Test-ModuleManifest -Path $manifestPath -ErrorAction Stop
        return $manifest.Version.ToString()
    }
    catch {
        return '0.0.0'
    }
}

function ConvertTo-HLFullPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Path
    )

    if ([IO.Path]::IsPathRooted($Path)) {
        return [IO.Path]::GetFullPath($Path)
    }
    # .NET resolves relative paths against the process working directory,
    # which PowerShell does not update on Set-Location; anchor to $PWD.
    $currentLocation = (Get-Location -PSProvider FileSystem).ProviderPath
    return [IO.Path]::GetFullPath((Join-Path -Path $currentLocation -ChildPath $Path))
}

function Get-HLSeverityWeight {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Critical', 'High', 'Medium', 'Low', 'Informational')]
        [string]$Severity
    )

    switch ($Severity) {
        'Critical' { return 10 }
        'High' { return 7 }
        'Medium' { return 4 }
        'Low' { return 1 }
        default { return 0 }
    }
}

function Get-HLSeverityRank {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Severity
    )

    switch ($Severity) {
        'Critical' { return 4 }
        'High' { return 3 }
        'Medium' { return 2 }
        'Low' { return 1 }
        default { return 0 }
    }
}

function ConvertTo-HLDisplayString {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$Value
    )

    if ($null -eq $Value) {
        return '<not set>'
    }

    if ($Value -is [string]) {
        return $Value
    }

    if ($Value -is [bool]) {
        return $Value.ToString()
    }

    if ($Value -is [System.Collections.IDictionary] -or $Value -is [pscustomobject]) {
        return ($Value | ConvertTo-Json -Depth 15 -Compress)
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        $items = @($Value | ForEach-Object { ConvertTo-HLDisplayString -Value $_ })
        return ($items -join ', ')
    }

    return [string]$Value
}

function Get-HLProbeResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Pass', 'Fail', 'Warning', 'Unknown', 'Error', 'NotApplicable')]
        [string]$Status,

        [AllowNull()]
        [object]$Expected,

        [AllowNull()]
        [object]$Actual,

        [string]$Message = '',

        [AllowNull()]
        [object]$Evidence = $null
    )

    return [pscustomobject][ordered]@{
        Status   = $Status
        Expected = $Expected
        Actual   = $Actual
        Message  = $Message
        Evidence = $Evidence
    }
}

function Get-HLControlCatalog {
    [CmdletBinding()]
    param()

    if ($null -ne $script:HLControlCatalogCache) {
        return $script:HLControlCatalogCache
    }

    $path = Join-Path -Path $script:HLDataRoot -ChildPath 'control-catalog.json'
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
        throw "Control catalog not found: $path"
    }

    $catalog = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json
    $script:HLControlCatalogCache = $catalog
    return $catalog
}

function Get-HLBuiltinBaselineName {
    [CmdletBinding()]
    param()

    return @('Workstation', 'MemberServer', 'DomainController', 'AVDSessionHost')
}

function Get-HLBuiltinBaselinePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Workstation', 'MemberServer', 'DomainController', 'AVDSessionHost')]
        [string]$Name
    )

    $baselineRoot = Join-Path -Path $script:HLDataRoot -ChildPath 'Baselines'
    return Join-Path -Path $baselineRoot -ChildPath ($Name + '.json')
}

function Merge-HLParameterObject {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$Base,

        [AllowNull()]
        [object]$Override
    )

    $merged = [ordered]@{}

    if ($null -ne $Base) {
        foreach ($property in $Base.PSObject.Properties) {
            $merged[$property.Name] = $property.Value
        }
    }

    if ($null -ne $Override) {
        foreach ($property in $Override.PSObject.Properties) {
            $merged[$property.Name] = $property.Value
        }
    }

    return [pscustomobject]$merged
}

function Resolve-HLControlDefinition {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$CatalogControl,

        [Parameter(Mandatory)]
        [object]$BaselineControl
    )

    $resolved = Copy-HLObject -InputObject $CatalogControl

    if ((Test-HLProperty -InputObject $BaselineControl -Name 'severity') -and -not [string]::IsNullOrWhiteSpace([string]$BaselineControl.severity)) {
        $resolved.severity = [string]$BaselineControl.severity
    }

    if (Test-HLProperty -InputObject $BaselineControl -Name 'parameters') {
        $resolved.parameters = Merge-HLParameterObject -Base $resolved.parameters -Override $BaselineControl.parameters
    }

    return $resolved
}

function Resolve-HLBaseline {
    [CmdletBinding()]
    param(
        [ValidateSet('Workstation', 'MemberServer', 'DomainController', 'AVDSessionHost')]
        [string]$Name,

        [string]$Path
    )

    $catalog = Get-HLControlCatalog
    $catalogById = @{}
    foreach ($control in @($catalog.controls)) {
        $catalogById[[string]$control.id] = $control
    }

    if (-not [string]::IsNullOrWhiteSpace($Path)) {
        $resolvedPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
        $custom = Get-Content -LiteralPath $resolvedPath -Raw -ErrorAction Stop | ConvertFrom-Json
        Assert-HLBaselineDocument -Document $custom -Catalog $catalog -Source "Custom baseline '$resolvedPath'"

        if ((Test-HLProperty -InputObject $custom -Name 'extends') -and -not [string]::IsNullOrWhiteSpace([string]$custom.extends)) {
            if ([string]$custom.extends -notin (Get-HLBuiltinBaselineName)) {
                throw "Custom baseline extends unsupported built-in baseline '$($custom.extends)'."
            }
            $basePath = Get-HLBuiltinBaselinePath -Name ([string]$custom.extends)
            $base = Get-Content -LiteralPath $basePath -Raw -ErrorAction Stop | ConvertFrom-Json
            if (Test-HLProperty -InputObject $custom -Name 'excludedControls') {
                $baseIds = @($base.controls | ForEach-Object { [string]$_.id })
                foreach ($excludedId in @($custom.excludedControls)) {
                    if ([string]$excludedId -notin $baseIds) {
                        throw "Custom baseline '$resolvedPath' excludes control '$excludedId', which is not present in '$($custom.extends)'."
                    }
                }
            }
        }
        else {
            $base = [pscustomobject][ordered]@{
                schemaVersion = '1.0'
                name          = [string]$custom.name
                displayName   = [string]$custom.displayName
                version       = [string]$custom.version
                description   = if (Test-HLProperty -InputObject $custom -Name 'description') { [string]$custom.description } else { '' }
                sourceBasis   = @()
                supportedRoles = @()
                controls      = @()
                notes         = @()
            }
        }

        $baseline = Copy-HLObject -InputObject $base

        foreach ($metadataName in @('name', 'displayName', 'version', 'description', 'sourceBasis', 'supportedRoles', 'notes')) {
            if (Test-HLProperty -InputObject $custom -Name $metadataName) {
                if ($null -eq $baseline.PSObject.Properties[$metadataName]) {
                    $baseline | Add-Member -NotePropertyName $metadataName -NotePropertyValue $custom.$metadataName
                }
                else {
                    $baseline.$metadataName = $custom.$metadataName
                }
            }
        }

        $controlMap = [ordered]@{}
        foreach ($control in @($baseline.controls)) {
            $controlMap[[string]$control.id] = $control
        }

        if (Test-HLProperty -InputObject $custom -Name 'excludedControls') {
            foreach ($excludedId in @($custom.excludedControls)) {
                [void]$controlMap.Remove([string]$excludedId)
            }
        }

        foreach ($override in @($custom.controls)) {
            $id = [string]$override.id
            $enabled = $true
            if (Test-HLProperty -InputObject $override -Name 'enabled') {
                $enabled = [bool]$override.enabled
            }

            if (-not $enabled) {
                [void]$controlMap.Remove($id)
                continue
            }

            if ($controlMap.Contains($id)) {
                $existing = $controlMap[$id]
                if (Test-HLProperty -InputObject $override -Name 'severity') {
                    if ($null -eq $existing.PSObject.Properties['severity']) {
                        $existing | Add-Member -NotePropertyName severity -NotePropertyValue $override.severity
                    }
                    else {
                        $existing.severity = $override.severity
                    }
                }
                if (Test-HLProperty -InputObject $override -Name 'parameters') {
                    $currentParameters = if (Test-HLProperty -InputObject $existing -Name 'parameters') { $existing.parameters } else { $null }
                    $mergedParameters = Merge-HLParameterObject -Base $currentParameters -Override $override.parameters
                    if ($null -eq $existing.PSObject.Properties['parameters']) {
                        $existing | Add-Member -NotePropertyName parameters -NotePropertyValue $mergedParameters
                    }
                    else {
                        $existing.parameters = $mergedParameters
                    }
                }
                $controlMap[$id] = $existing
            }
            else {
                $controlMap[$id] = $override
            }
        }

        $baseline.controls = @($controlMap.Values)
        $baseline | Add-Member -NotePropertyName sourcePath -NotePropertyValue $resolvedPath -Force
    }
    else {
        if ([string]::IsNullOrWhiteSpace($Name)) {
            throw 'A baseline name or baseline path is required.'
        }
        $baselinePath = Get-HLBuiltinBaselinePath -Name $Name
        $baseline = Get-Content -LiteralPath $baselinePath -Raw -ErrorAction Stop | ConvertFrom-Json
        Assert-HLBaselineDocument -Document $baseline -Catalog $catalog -Source "Built-in baseline '$Name'"
        $baseline | Add-Member -NotePropertyName sourcePath -NotePropertyValue $baselinePath -Force
    }

    $seen = @{}
    $resolvedControls = New-Object System.Collections.Generic.List[object]
    foreach ($baselineControl in @($baseline.controls)) {
        $id = [string]$baselineControl.id
        if ($seen.ContainsKey($id)) {
            throw "Baseline '$($baseline.name)' contains duplicate control '$id'."
        }
        $seen[$id] = $true

        if (-not $catalogById.ContainsKey($id)) {
            throw "Baseline '$($baseline.name)' references unknown control '$id'."
        }

        $resolvedControls.Add((Resolve-HLControlDefinition -CatalogControl $catalogById[$id] -BaselineControl $baselineControl))
    }

    $baseline.controls = $resolvedControls.ToArray()
    $baseline | Add-Member -NotePropertyName controlCount -NotePropertyValue $resolvedControls.Count -Force
    return $baseline
}

function Get-HLSystemContext {
    [CmdletBinding()]
    param()

    Assert-HLWindows

    $os = $null
    $computerSystem = $null
    try {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
    }
    catch {
        Write-Verbose "Unable to query Win32_OperatingSystem: $($_.Exception.Message)"
    }

    try {
        $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
    }
    catch {
        Write-Verbose "Unable to query Win32_ComputerSystem: $($_.Exception.Message)"
    }

    $productType = if ($null -ne $os -and $null -ne $os.ProductType) { [int]$os.ProductType } else { 0 }
    $role = switch ($productType) {
        1 { 'Workstation' }
        2 { 'DomainController' }
        3 { 'MemberServer' }
        default { 'Unknown' }
    }

    $computerName = if (-not [string]::IsNullOrWhiteSpace($env:COMPUTERNAME)) { $env:COMPUTERNAME } else { [Environment]::MachineName }
    $domain = if ($null -ne $computerSystem) { [string]$computerSystem.Domain } else { [string]$env:USERDOMAIN }
    $currentUser = try { [Security.Principal.WindowsIdentity]::GetCurrent().Name } catch { [Environment]::UserName }

    return [pscustomobject][ordered]@{
        ComputerName      = $computerName
        Domain            = $domain
        DomainJoined      = if ($null -ne $computerSystem) { [bool]$computerSystem.PartOfDomain } else { $null }
        DetectedRole      = $role
        ProductType       = $productType
        OSCaption         = if ($null -ne $os) { [string]$os.Caption } else { [Environment]::OSVersion.VersionString }
        OSVersion         = if ($null -ne $os) { [string]$os.Version } else { [Environment]::OSVersion.Version.ToString() }
        BuildNumber       = if ($null -ne $os) { [string]$os.BuildNumber } else { [Environment]::OSVersion.Version.Build.ToString() }
        OSArchitecture    = if ($null -ne $os) { [string]$os.OSArchitecture } else { [string]$env:PROCESSOR_ARCHITECTURE }
        Manufacturer      = if ($null -ne $computerSystem) { [string]$computerSystem.Manufacturer } else { '' }
        Model             = if ($null -ne $computerSystem) { [string]$computerSystem.Model } else { '' }
        PowerShellVersion = $PSVersionTable.PSVersion.ToString()
        PowerShellEdition = if ($null -ne $PSVersionTable.PSObject.Properties['PSEdition']) { [string]$PSVersionTable.PSEdition } else { 'Desktop' }
        CurrentUser       = $currentUser
        IsElevated        = Test-HLIsElevated
    }
}

function Get-HLAutoBaselineName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$SystemContext
    )

    switch ([int]$SystemContext.ProductType) {
        1 { return 'Workstation' }
        2 { return 'DomainController' }
        3 { return 'MemberServer' }
        default { throw 'Unable to determine a role-aware baseline automatically. Specify -Baseline explicitly.' }
    }
}

function Get-HLSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$Results
    )

    $statusNames = @('Pass', 'Fail', 'Warning', 'Excepted', 'Unknown', 'Error', 'NotApplicable')
    $counts = [ordered]@{}
    foreach ($status in $statusNames) {
        $counts[$status] = @($Results | Where-Object { $_.status -eq $status }).Count
    }

    $weightedTotal = 0.0
    $weightedPass = 0.0
    $applicableCount = 0
    $coveredCount = 0

    foreach ($result in $Results) {
        if ($result.status -eq 'NotApplicable') {
            continue
        }

        $applicableCount++
        if ($result.status -notin @('Unknown', 'Error')) {
            $coveredCount++
        }

        $weight = Get-HLSeverityWeight -Severity ([string]$result.severity)
        if ($weight -le 0) {
            continue
        }

        $weightedTotal += $weight
        if ($result.status -eq 'Pass') {
            $weightedPass += $weight
        }
    }

    $score = if ($weightedTotal -gt 0) { [math]::Round(($weightedPass / $weightedTotal) * 100, 1) } else { $null }
    $coverage = if ($applicableCount -gt 0) { [math]::Round(($coveredCount / $applicableCount) * 100, 1) } else { $null }

    $highestOpenSeverity = 'None'
    $open = @($Results | Where-Object { $_.status -in @('Fail', 'Warning', 'Excepted', 'Error') })
    if ($open.Count -gt 0) {
        $highest = $open | Sort-Object @{ Expression = { Get-HLSeverityRank -Severity ([string]$_.severity) }; Descending = $true }, controlId | Select-Object -First 1
        $highestOpenSeverity = [string]$highest.severity
    }

    return [pscustomobject][ordered]@{
        Total               = $Results.Count
        Applicable          = $applicableCount
        Pass                = $counts.Pass
        Fail                = $counts.Fail
        Warning             = $counts.Warning
        Excepted            = $counts.Excepted
        Unknown             = $counts.Unknown
        Error               = $counts.Error
        NotApplicable       = $counts.NotApplicable
        HardeningScore      = $score
        EvidenceCoverage    = $coverage
        HighestOpenSeverity = $highestOpenSeverity
        ScoringModel        = 'Severity-weighted pass percentage. Exceptions, unknowns, warnings, and errors receive no pass credit; not-applicable and informational controls are excluded.'
    }
}

function ConvertTo-HLRedactedObject {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$InputObject,

        [Parameter(Mandatory)]
        [hashtable]$ReplacementMap
    )

    if ($null -eq $InputObject) {
        return $null
    }

    if ($InputObject -is [string]) {
        $value = [string]$InputObject
        $keys = @($ReplacementMap.Keys | Sort-Object { ([string]$_).Length } -Descending)
        foreach ($key in $keys) {
            if (-not [string]::IsNullOrWhiteSpace([string]$key)) {
                $value = $value -replace [regex]::Escape([string]$key), [string]$ReplacementMap[$key]
            }
        }
        return $value
    }

    if ($InputObject -is [System.Collections.IDictionary]) {
        $dictionary = [ordered]@{}
        foreach ($key in $InputObject.Keys) {
            $dictionary[$key] = ConvertTo-HLRedactedObject -InputObject $InputObject[$key] -ReplacementMap $ReplacementMap
        }
        return [pscustomobject]$dictionary
    }

    if ($InputObject -is [pscustomobject]) {
        $properties = [ordered]@{}
        foreach ($property in $InputObject.PSObject.Properties) {
            $properties[$property.Name] = ConvertTo-HLRedactedObject -InputObject $property.Value -ReplacementMap $ReplacementMap
        }
        return [pscustomobject]$properties
    }

    if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) {
        $items = New-Object System.Collections.Generic.List[object]
        foreach ($item in $InputObject) {
            $items.Add((ConvertTo-HLRedactedObject -InputObject $item -ReplacementMap $ReplacementMap))
        }
        return $items.ToArray()
    }

    return $InputObject
}

function Protect-HLResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$ScanResult
    )

    $copy = Copy-HLObject -InputObject $ScanResult
    $replacementMap = @{}

    if (Test-HLProperty -InputObject $copy -Name 'system') {
        if (-not [string]::IsNullOrWhiteSpace([string]$copy.system.ComputerName)) {
            $replacementMap[[string]$copy.system.ComputerName] = 'HOST-REDACTED'
        }
        if (-not [string]::IsNullOrWhiteSpace([string]$copy.system.Domain)) {
            $replacementMap[[string]$copy.system.Domain] = 'DOMAIN.REDACTED'
        }
        if (-not [string]::IsNullOrWhiteSpace([string]$copy.system.CurrentUser)) {
            $replacementMap[[string]$copy.system.CurrentUser] = 'USER-REDACTED'
        }
    }

    $redacted = ConvertTo-HLRedactedObject -InputObject $copy -ReplacementMap $replacementMap
    $redacted.scan.redacted = $true
    return $redacted
}

function Write-HLConsoleSummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$ScanResult
    )

    $summary = $ScanResult.summary
    $scoreText = if ($null -eq $summary.HardeningScore) { 'n/a' } else { '{0:N1}%' -f [double]$summary.HardeningScore }
    $coverageText = if ($null -eq $summary.EvidenceCoverage) { 'n/a' } else { '{0:N1}%' -f [double]$summary.EvidenceCoverage }

    Write-Host ''
    Write-Host ('HARDENING LENS // {0}' -f $ScanResult.system.ComputerName) -ForegroundColor Cyan
    Write-Host ('Baseline: {0} {1} | Score: {2} | Coverage: {3}' -f $ScanResult.baseline.displayName, $ScanResult.baseline.version, $scoreText, $coverageText)
    Write-Host ('PASS {0} FAIL {1} WARN {2} EXCEPTED {3} UNKNOWN {4} ERROR {5} N/A {6}' -f $summary.Pass, $summary.Fail, $summary.Warning, $summary.Excepted, $summary.Unknown, $summary.Error, $summary.NotApplicable)

    $findings = @($ScanResult.results | Where-Object { $_.status -in @('Fail', 'Error', 'Warning') } | Sort-Object @{ Expression = { Get-HLSeverityRank -Severity ([string]$_.severity) }; Descending = $true }, controlId | Select-Object -First 10)
    if ($findings.Count -gt 0) {
        Write-Host ''
        Write-Host 'Top findings:' -ForegroundColor Yellow
        foreach ($finding in $findings) {
            Write-Host ('[{0,-8}] [{1,-8}] {2} {3}' -f $finding.severity.ToUpperInvariant(), $finding.status.ToUpperInvariant(), $finding.controlId, $finding.title)
        }
    }
    Write-Host ''
}