Private/Logging.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================
#region Logging

function Protect-VcfCheckLogMessage {

    <#
        .SYNOPSIS
        Redacts known secret-bearing patterns from a message before it is logged or reported.

        .DESCRIPTION
        A denylist of known secret-bearing patterns (HTTP Authorization headers, Basic/Bearer
        auth schemes, JSON password/token fields). This is a defense-in-depth backstop, not the
        primary control—the primary control is that this module never builds a command string
        or log message containing a plaintext secret in the first place (guest-ops credentials
        are passed via -GuestCredential parameter binding, never embedded in ScriptText).
        Applied to every message Write-LogMessage writes, and to Exception text before it is
        placed in a VcfCheck.Result.

        .PARAMETER Message
        The raw message text.

        .OUTPUTS
        [String] the redacted message.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [AllowEmptyString()] [String]$Message
    )

    $redacted = $Message
    $redacted = $redacted -replace '(?i)Authorization:\s*[^\r\n]+', 'Authorization: [REDACTED]'
    $redacted = $redacted -replace '(?i)Basic\s+[A-Za-z0-9+/=]{8,}', 'Basic [REDACTED]'
    $redacted = $redacted -replace '(?i)Bearer\s+[A-Za-z0-9\-._~+/]{8,}=*', 'Bearer [REDACTED]'
    $redacted = $redacted -replace '(?i)("(?:password|token|secret|pwd)"\s*:\s*)"[^"]*"', '$1"[REDACTED]"'
    $redacted = $redacted -replace '(?i)(-w\s+|--password[= ]|-GuestPassword\s+)\S+', '$1[REDACTED]'
    return $redacted
}
function Write-LogMessage {

    <#
        .SYNOPSIS
        Writes a timestamped, type-prefixed log message to console and/or log file.

        .DESCRIPTION
        Screen output is filtered by the configured log level threshold (set via
        Initialize-VcfCheckLogging). Only messages at or above the configured level are
        displayed on the console. All messages are always written to the log file regardless
        of level, so DEBUG context needed to diagnose a run is never silently discarded. When
        the orchestrator is executing a check, it sets $Script:VcfCheckCurrentCheckId to
        that check's ID, and every message logged during execution is tagged with
        "[<CheckId>]" so a given log line can be traced back to the check that produced it.

        .PARAMETER Type
        Message type: DEBUG, INFO, WARNING, ERROR.

        .PARAMETER Message
        The message text. Never pass a plaintext secret.

        .EXAMPLE
        Write-LogMessage -Type INFO -Message "Connected to SDDC Manager vcf01-sddcmgr01."

        .EXAMPLE
        Write-LogMessage -Type ERROR -Message "Check sddc_lock_table failed: $($_.Exception.Message)"
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [ValidateSet('DEBUG', 'INFO', 'WARNING', 'ERROR')] [String]$Type,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Message,
        [Parameter(Mandatory = $false)] [Switch]$NoNewline
    )

    $levelOrder = @{ 'DEBUG' = 0; 'INFO' = 1; 'WARNING' = 2; 'ERROR' = 3 }
    $configuredLevel = if ($Script:VcfCheckLogLevel) { $Script:VcfCheckLogLevel } else { 'INFO' }
    $aboveScreenThreshold = $levelOrder[$Type] -ge $levelOrder[$configuredLevel]

    $sanitizedMessage = Protect-VcfCheckLogMessage -Message $Message
    $timestamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff')
    $checkTag = if ($Script:VcfCheckCurrentCheckId) { " [$($Script:VcfCheckCurrentCheckId)]" } else { '' }
    $formattedMessage = "[$timestamp] [$Type]$checkTag $sanitizedMessage"

    if ($aboveScreenThreshold) {
        switch ($Type) {
            'DEBUG'   { Write-Host $formattedMessage -ForegroundColor Gray -NoNewline:$NoNewline }
            'INFO'    { Write-Host $formattedMessage -ForegroundColor White -NoNewline:$NoNewline }
            'WARNING' { Write-Host $formattedMessage -ForegroundColor Yellow -NoNewline:$NoNewline }
            'ERROR'   { Write-Host $formattedMessage -ForegroundColor Red -NoNewline:$NoNewline }
        }
    }

    if ($Script:VcfCheckLogFilePath) {
        try {
            $fileExists = Test-Path -LiteralPath $Script:VcfCheckLogFilePath
            Add-Content -LiteralPath $Script:VcfCheckLogFilePath -Value $formattedMessage -ErrorAction Stop

            if (-not $fileExists -and $PSVersionTable.Platform -ne 'Win32NT') {
                & chmod 600 $Script:VcfCheckLogFilePath 2>$null
            }
        } catch {
            Write-Host "Warning: Could not write to log file: $($_.Exception.Message)" -ForegroundColor Yellow
        }
    }
}
function Get-VcfCheckLogDirectory {

    <#
        .SYNOPSIS
        Returns the directory where VcfCheck logs are written.

        .DESCRIPTION
        Returns the configured log directory if logging has been initialized, otherwise
        the default Logs/ directory relative to the module installation directory. Useful
        for the bundled Python report server, which tails the same log directory.

        .OUTPUTS
        [String] Fully qualified path to the log directory.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param ()

    if ($Script:VcfCheckLogDirectory) {
        return $Script:VcfCheckLogDirectory
    }

    return Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'Logs'
}
function Initialize-VcfCheckLogging {

    <#
        .SYNOPSIS
        Initializes logging for a VcfCheck run.

        .DESCRIPTION
        Resolves the log directory (explicit param > $env:VcfCheckBaseDirectory\Logs),
        creates it with owner-only permissions if missing, and opens a new dated log file.
        All severities are always written to the file; only messages at or above LogLevel
        are echoed to the console.

        .PARAMETER LogDirectory
        Absolute or relative path to the log directory. When omitted, resolved from
        $env:VcfCheckBaseDirectory. Throws if neither is available.

        .PARAMETER LogLevel
        Minimum log level to display on console: DEBUG, INFO, WARNING, ERROR. Default INFO.

        .OUTPUTS
        [String] Absolute path to the active log file.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$LogDirectory = '',
        [Parameter(Mandatory = $false)] [ValidateSet('DEBUG', 'INFO', 'WARNING', 'ERROR')] [String]$LogLevel = 'INFO'
    )

    $Script:VcfCheckLogLevel = $LogLevel

    if ([String]::IsNullOrWhiteSpace($LogDirectory)) {
        if ([String]::IsNullOrWhiteSpace($env:VcfCheckBaseDirectory)) {
            throw [System.InvalidOperationException]::new(
                "`$env:$($Script:VCF_CHECK_ENV_VAR) is not set. Run Initialize-VcfCheck before starting a precheck run, or pass -LogDirectory explicitly."
            )
        }
        $Script:VcfCheckLogDirectory = Join-Path -Path $env:VcfCheckBaseDirectory.Trim() -ChildPath $Script:CHECK_LOGS_DIR_NAME
    } elseif ([System.IO.Path]::IsPathRooted($LogDirectory)) {
        $Script:VcfCheckLogDirectory = $LogDirectory
    } else {
        $Script:VcfCheckLogDirectory = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath $LogDirectory
    }

    if (-not (Test-Path -LiteralPath $Script:VcfCheckLogDirectory -PathType Container)) {
        $null = New-Item -ItemType Directory -Path $Script:VcfCheckLogDirectory -Force
        if ($PSVersionTable.Platform -ne 'Win32NT') {
            & chmod 700 $Script:VcfCheckLogDirectory 2>$null
        }
    }

    # Naming uses a PascalCase prefix, ISO-like hyphenated date, one file per calendar day,
    # and no per-run ID in the name.
    $fileTimeStamp = Get-Date -Format 'yyyy-MM-dd'
    $Script:VcfCheckLogFilePath = Join-Path -Path $Script:VcfCheckLogDirectory -ChildPath "VcfCheckEngine-$fileTimeStamp.log"

    if (-not (Test-Path -LiteralPath $Script:VcfCheckLogFilePath)) {
        New-Item -ItemType File -Path $Script:VcfCheckLogFilePath -Force | Out-Null
    }

    return $Script:VcfCheckLogFilePath
}
function Write-VcfCheckRuntimeInfo {

    <#
        .SYNOPSIS
        Logs PowerShell, PowerCLI, VcfCheck module, and environment information to the log.

        .DESCRIPTION
        Writes a single INFO log line with PowerShell version, VMware PowerCLI version,
        VcfCheck module version, Python version, and OS. Called once per precheck run
        immediately after Initialize-VcfCheckLogging. Detects Python version from the
        environment variable if set, otherwise attempts to query the python3, python, or py
        executable on PATH, logging a DEBUG line with the reason when detection fails.
        Non-fatal — continues even if detection fails.

        .EXAMPLE
        Initialize-VcfCheckLogging -LogDirectory $LogDirectory | Out-Null
        Write-VcfCheckRuntimeInfo
    #>


    [CmdletBinding()]
    Param ()

    try {
        $pcliMod = Get-Module -Name 'VCF.PowerCLI' -ListAvailable -ErrorAction SilentlyContinue |
            Sort-Object { [Version]$_.Version } -Descending | Select-Object -First 1
        $checkMod = Get-Module -Name 'VcfCheck' -ErrorAction SilentlyContinue
        $pcliVer = if ($pcliMod) { $pcliMod.Version.ToString() } else { 'not loaded' }
        $checkVer = if ($checkMod) { $checkMod.Version.ToString() } else { 'unknown' }

        # Detect Python version from environment variable or by querying executable
        $pyVer = 'unknown'
        if ($env:VCF_CHECK_PYTHON_VERSION) {
            $pyVer = $env:VCF_CHECK_PYTHON_VERSION
        } else {
            try {
                $pythonExe = @('python3', 'python', 'py') |
                    Where-Object { $null -ne (Get-Command $_ -ErrorAction SilentlyContinue) } |
                    Select-Object -First 1
                if ($pythonExe) {
                    $pyVersionOutput = & $pythonExe --version 2>&1
                    if ($pyVersionOutput -match '(\d+\.\d+(?:\.\d+)?)') {
                        $pyVer = $matches[1]
                    } else {
                        Write-LogMessage -Type DEBUG -Message "Python detection: '$pythonExe --version' produced unparsable output '$pyVersionOutput'"
                    }
                } else {
                    Write-LogMessage -Type DEBUG -Message 'Python detection: no python3, python, or py executable found on PATH'
                }
            } catch {
                Write-LogMessage -Type DEBUG -Message "Python detection failed: $($_.Exception.Message)"
            }
        }

        # $PSVersionTable.OS reports the kernel build (e.g. "10.0.20348"), which is ambiguous
        # between Windows Server 2022 and Windows 10/11 21H2+. ProductName from the registry
        # disambiguates on Windows; other platforms keep the $PSVersionTable.OS string.
        $osInfo = $PSVersionTable.OS
        if ($PSVersionTable.Platform -eq 'Win32NT') {
            try {
                $winVer = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
                if ($winVer.ProductName) {
                    $osInfo = "$($winVer.ProductName) $($winVer.CurrentBuildNumber)"
                }
            } catch {
                Write-LogMessage -Type DEBUG -Message "Windows ProductName detection failed: $($_.Exception.Message)"
            }
        }

        Write-LogMessage -Type INFO -Message "Runtime: PowerShell=$($PSVersionTable.PSVersion) | VCF.PowerCLI=$pcliVer | VcfCheck=v$checkVer | Python=$pyVer | OS=$osInfo"
    } catch {
        # Non-fatal: still log even if something fails above
        Write-LogMessage -Type INFO -Message "Runtime: PowerShell=$($PSVersionTable.PSVersion) | VCF.PowerCLI=unknown | VcfCheck=unknown | Python=unknown | OS=$($PSVersionTable.OS)"
    }

    Write-VcfCheckProcessIdentityInfo
}
function Write-VcfCheckProcessIdentityInfo {

    <#
        .SYNOPSIS
        Logs the current process identity, elevation, and PowerCLI configuration scopes.

        .DESCRIPTION
        Writes a single DEBUG log line with the current OS account, whether it is elevated,
        and the Session/User/AllUsers-scope InvalidCertificateAction/DefaultVIServerMode
        values as seen by this process. Read-only - never changes any configuration.
        Called once per run from Write-VcfCheckRuntimeInfo, and again immediately before each
        guest-ops call in Invoke-VcfApplianceCommand, so the values are captured even when that
        specific call fails or hangs before the run-start snapshot would otherwise be useful.

        PowerCLI's documented precedence is Session (highest) > User > AllUsers (lowest) - a
        set Session value should already win regardless of User/AllUsers. But Session is
        per-process and in-memory only, never persisted to disk, so a launcher subprocess (a
        fresh "pwsh -NoProfile -NonInteractive" per check run) never inherits the operator's own
        interactive-shell Session setting - it starts with Session unset and should then fall
        through to User, then AllUsers. Confirmed against a customer environment where that
        fallthrough did not happen for Invoke-VMScript guest-ops specifically: Session was unset
        in the subprocess, User had no override, and AllUsers reported "Ignore", yet guest-ops
        calls still failed TLS validation until the User scope was set explicitly
        (Set-PowerCLIConfiguration -Scope User -InvalidCertificateAction Ignore). So this logs a
        WARNING whenever Session has no override of its own, User has no override either, and
        AllUsers is "Ignore" - the one combination where the documented fallthrough to AllUsers
        is known not to reliably apply to guest-ops calls.

        .EXAMPLE
        Write-VcfCheckProcessIdentityInfo
    #>


    [CmdletBinding()]
    Param ()

    try {
        $identityName = 'unknown'
        $isElevated = 'unknown'
        if ($PSVersionTable.Platform -eq 'Win32NT') {
            # $env:USERDOMAIN/$env:USERNAME avoid the SID-to-account-name LSA lookup that
            # WindowsIdentity.GetCurrent().Name performs, which can hang when AD/DNS is unreachable.
            $identityName = "$env:USERDOMAIN\$env:USERNAME"
            $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
        } else {
            $identityName = & whoami 2>$null
            $isElevated = ($env:SUDO_USER -or ($null -ne (& id -u 2>$null) -and (& id -u 2>$null) -eq '0'))
        }

        $sessionConfig = Get-PowerCLIConfiguration -Scope Session -ErrorAction SilentlyContinue
        $userConfig = Get-PowerCLIConfiguration -Scope User -ErrorAction SilentlyContinue
        $allUsersConfig = Get-PowerCLIConfiguration -Scope AllUsers -ErrorAction SilentlyContinue

        Write-LogMessage -Type DEBUG -Message "Process identity=$identityName | Elevated=$isElevated | PowerCLI Session(InvalidCertificateAction=$($sessionConfig.InvalidCertificateAction), DefaultVIServerMode=$($sessionConfig.DefaultVIServerMode)) | User(InvalidCertificateAction=$($userConfig.InvalidCertificateAction), DefaultVIServerMode=$($userConfig.DefaultVIServerMode)) | AllUsers(InvalidCertificateAction=$($allUsersConfig.InvalidCertificateAction), DefaultVIServerMode=$($allUsersConfig.DefaultVIServerMode))"

        $sessionHasNoOverride = [String]::IsNullOrWhiteSpace($sessionConfig.InvalidCertificateAction) -or $sessionConfig.InvalidCertificateAction -eq 'Unset'
        $userHasNoOverride = [String]::IsNullOrWhiteSpace($userConfig.InvalidCertificateAction) -or $userConfig.InvalidCertificateAction -eq 'Unset'
        if ($sessionHasNoOverride -and $userHasNoOverride -and $allUsersConfig.InvalidCertificateAction -eq 'Ignore') {
            Write-LogMessage -Type WARNING -Message 'PowerCLI InvalidCertificateAction is "Ignore" at AllUsers scope only - Session and User have no override in this process. Guest-ops calls (Invoke-VMScript) have been observed to not reliably fall through to AllUsers the way PowerCLI''s documented Session > User > AllUsers precedence implies, and can still fail TLS certificate validation. If a guest-ops call fails with a certificate error, run: Set-PowerCLIConfiguration -Scope User -InvalidCertificateAction Ignore'
        }
    } catch {
        Write-LogMessage -Type DEBUG -Message "Process identity/PowerCLI configuration detection failed: $($_.Exception.Message)"
    }
}

#endregion Logging