Private/ManifestVerification.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 Manifest Verification — Supply-Chain Security
function New-ManifestFile {

    <#
        .SYNOPSIS
        Creates a SHA256 manifest file for the module.

        .DESCRIPTION
        Computes SHA256 hashes for all module files and writes them to a manifest
        file in the format: <sha256> <filename>

        This manifest can be published alongside the module on GitHub so that users
        can verify the module's integrity before running it.

        .PARAMETER ModulePath
        Path to the VcfPatchScanner module directory.

        .PARAMETER ManifestPath
        Path where the manifest file will be written.
        Default: MANIFEST.sha256 in the module directory.

        .OUTPUTS
        The path to the created manifest file.

        .NOTES
        The manifest includes all module files (Private/, .ps1/.psd1 files).
        It does NOT include Tests/ or development files.

        This is used by the Publish-VcfPatchScanner.ps1 script to generate manifests
        for each release, which are then published to GitHub releases.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ModulePath,
        [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$ManifestPath = (Join-Path $ModulePath "MANIFEST.sha256")
    )

    if (-not (Test-Path -LiteralPath $ModulePath -PathType Container)) {
        throw "Module path not found: $ModulePath"
    }

    $manifestLines = @()

    # Files at module root to include
    $rootFiles = @("VcfPatchScanner.psd1", "VcfPatchScanner.psm1", "PSScriptAnalyzerSettings.psd1")
    foreach ($fileName in $rootFiles) {
        $filePath = Join-Path $ModulePath $fileName
        if (Test-Path -LiteralPath $filePath -PathType Leaf) {
            $hash = (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash.ToLower()
            $manifestLines += "$hash $fileName"
        }
    }

    # Directories to include recursively
    $dirsToInclude = @("Private")
    foreach ($dirName in $dirsToInclude) {
        $dirPath = Join-Path $ModulePath $dirName
        if (Test-Path -LiteralPath $dirPath -PathType Container) {
            Get-ChildItem -LiteralPath $dirPath -Recurse -File |
                Where-Object { $_.Name -ne ".DS_Store" } |
                Sort-Object FullName |
                ForEach-Object {
                    $relPath = [IO.Path]::GetRelativePath($ModulePath, $_.FullName).Replace('\', '/')
                    $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLower()
                    $manifestLines += "$hash $relPath"
                }
        }
    }

    # Write manifest
    $manifestLines | Sort-Object | Set-Content -LiteralPath $ManifestPath -Encoding UTF8 -Force
    Write-LogMessage -Type INFO -Message "Generated manifest: $ManifestPath ($($manifestLines.Count) files)"

    return $ManifestPath
}
function Test-ModuleManifest {

    <#
        .SYNOPSIS
        Verifies module integrity against a published SHA256 manifest.

        .DESCRIPTION
        Downloads or reads a SHA256 manifest from GitHub and verifies that the
        currently-loaded module matches the manifest. If the module's SHA256
        hashes do not match the manifest, returns failure and logs an error.

        This function is called at module initialization to detect supply-chain
        attacks (e.g., PSGallery account compromise, MITM during download).

        .PARAMETER ModulePath
        Path to the VcfPatchScanner module directory.

        .PARAMETER ManifestUrl
        URL to download the manifest from (GitHub releases page).
        If omitted, verification is skipped (optional safety check).

        .OUTPUTS
        $true if the module matches the manifest (or manifest not available).
        $false if the module does not match the manifest.

        .NOTES
        Returns $true (success) when:
        - Manifest URL is not provided (graceful degradation)
        - All files match the manifest

        Returns $false (failure) when:
        - Manifest cannot be downloaded
        - Module files do not match the manifest (indicates tampering)

        Failures are logged at WARNING level, not ERROR, so they don't block
        module initialization. However, operators should investigate any
        manifest mismatches, as they may indicate a supply-chain compromise.
    #>


    [CmdletBinding()]
    [OutputType([Bool])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ModulePath,
        [Parameter(Mandatory = $false)] [String]$ManifestUrl
    )

    if ([String]::IsNullOrWhiteSpace($ManifestUrl)) {
        # Manifest verification is optional; if no URL is provided, skip it.
        return $true
    }

    if (-not (Test-Path -LiteralPath $ModulePath -PathType Container)) {
        Write-LogMessage -Type WARNING -Message "Module path not found during manifest check: $ModulePath"
        return $false
    }

    # Download the manifest
    $manifestContent = $null
    try {
        Write-LogMessage -Type DEBUG -Message "Downloading module manifest from: $ManifestUrl"
        $manifestContent = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing -ErrorAction Stop | Select-Object -ExpandProperty Content
    } catch {
        Write-LogMessage -Type WARNING -Message "Could not download module manifest (supply-chain verification skipped): $($_.Exception.Message)"
        return $true  # Graceful degradation: if manifest is unavailable, allow load to proceed
    }

    # Build current module manifest
    $currentManifest = @()
    $rootFiles = @("VcfPatchScanner.psd1", "VcfPatchScanner.psm1", "PSScriptAnalyzerSettings.psd1")
    foreach ($fileName in $rootFiles) {
        $filePath = Join-Path $ModulePath $fileName
        if (Test-Path -LiteralPath $filePath -PathType Leaf) {
            $hash = (Get-FileHash -LiteralPath $filePath -Algorithm SHA256).Hash.ToLower()
            $currentManifest += "$hash $fileName"
        }
    }

    $dirsToInclude = @("Private")
    foreach ($dirName in $dirsToInclude) {
        $dirPath = Join-Path $ModulePath $dirName
        if (Test-Path -LiteralPath $dirPath -PathType Container) {
            Get-ChildItem -LiteralPath $dirPath -Recurse -File |
                Where-Object { $_.Name -ne ".DS_Store" } |
                Sort-Object FullName |
                ForEach-Object {
                    $relPath = [IO.Path]::GetRelativePath($ModulePath, $_.FullName).Replace('\', '/')
                    $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLower()
                    $currentManifest += "$hash $relPath"
                }
        }
    }

    $currentManifestSorted = @($currentManifest | Sort-Object) -join "`n"
    $publishedManifestSorted = @($manifestContent -split "`n" | Where-Object { $_.Trim() } | Sort-Object) -join "`n"

    if ($currentManifestSorted -ne $publishedManifestSorted) {
        Write-LogMessage -Type WARNING -Message "Module manifest verification FAILED. This may indicate supply-chain tampering (PSGallery compromise, MITM attack, or local file modification). Investigate immediately: verify the manifest URL against GitHub releases and re-download the module if necessary."
        return $false
    }

    Write-LogMessage -Type DEBUG -Message "Module manifest verification passed."
    return $true
}
#endregion