microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3a3a0fdf923d96a9e8a9ac734c73f24433b525e8

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/security/Test-DependencyPinning.ps1

1166lines · modepreview

#!/usr/bin/env pwsh
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
#Requires -Version 7.0

<#
.SYNOPSIS
    Verifies and reports on dependency pinning compliance for supply chain security.

.DESCRIPTION
    Cross-platform PowerShell script that analyzes GitHub Actions workflows, Docker images,
    and other dependency declarations to verify compliance with dependency pinning security practices.
    Identifies unpinned dependencies and provides remediation guidance.

.PARAMETER Path
    Root path to scan for dependency files. Defaults to current directory.

.PARAMETER Recursive
    Scan recursively through subdirectories. Default is true.

.PARAMETER Format
    Output format for compliance report. Options: json, sarif, csv, markdown, table.
    Default is 'json' for programmatic processing.

.PARAMETER OutputPath
    Path where compliance results should be saved. Defaults to 'dependency-pinning-report.json'
    in the current directory.

.PARAMETER FailOnUnpinned
    Exit with error code if pinning violations are found. Default is false for reporting mode.

.PARAMETER ExcludePaths
    Comma-separated list of paths to exclude from scanning (glob patterns supported).

.PARAMETER IncludeTypes
    Comma-separated list of dependency types to check. Options: github-actions, npm, pip.
    Default is all types.

.PARAMETER Threshold
    Minimum compliance score percentage required for passing grade (0-100).
    Script will exit with code 1 if compliance falls below threshold when -FailOnUnpinned is set.
    Default is 95%.

.PARAMETER Remediate
    Generate remediation suggestions with specific SHA pins for unpinned dependencies.

.EXAMPLE
    ./Test-DependencyPinning.ps1
    Scan current directory for dependency pinning compliance.

.EXAMPLE
    ./Test-DependencyPinning.ps1 -Path "/workspace" -Format "sarif" -FailOnUnpinned
    Scan workspace directory, output SARIF format, fail on violations.

.EXAMPLE
    ./Test-DependencyPinning.ps1 -IncludeTypes "github-actions,pip" -Remediate
    Check only GitHub Actions and pip dependencies with remediation suggestions.

.EXAMPLE
    ./Test-DependencyPinning.ps1 -Threshold 90 -FailOnUnpinned
    Enforce 90% compliance threshold and fail build if not met.

.EXAMPLE
    ./Test-DependencyPinning.ps1 -Threshold 100 -IncludeTypes "github-actions"
    Require 100% SHA pinning for GitHub Actions only.

.EXAMPLE
    ./Test-DependencyPinning.ps1 -Threshold 80
    Report compliance against 80% threshold but continue on violations.

.NOTES
    Requires:
    - PowerShell 7.0 or later for cross-platform compatibility
    - Internet connectivity for SHA resolution (with -Remediate)
    - GitHub API access for action SHA resolution (optional)

    Compatible with:
    - Windows PowerShell 5.1+ (limited cross-platform features)
    - PowerShell 7.x on Windows, Linux, macOS
    - GitHub Actions runners (ubuntu-latest, windows-latest, macos-latest)
    - Azure DevOps agents (Microsoft-hosted and self-hosted)

.LINK
    https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions
#>

# Import security classes from shared module
using module ./Modules/SecurityClasses.psm1

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [string]$Path = ".",

    [Parameter(Mandatory = $false)]
    [ValidateSet('json', 'sarif', 'csv', 'markdown', 'table')]
    [string]$Format = 'json',

    [Parameter(Mandatory = $false)]
    [string]$OutputPath = 'logs/dependency-pinning-results.json',

    [Parameter(Mandatory = $false)]
    [switch]$FailOnUnpinned,

    [Parameter(Mandatory = $false)]
    [string]$ExcludePaths = "",

    [Parameter(Mandatory = $false)]
    [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads,workflow-npm-commands",

    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 100)]
    [int]$Threshold = 95,

    [Parameter(Mandatory = $false)]
    [switch]$Remediate
)

$ErrorActionPreference = 'Stop'

# Import CIHelpers for workflow command escaping
Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force

# Define dependency patterns for different ecosystems
$DependencyPatterns = @{
    'github-actions' = @{
        FilePatterns    = @('**/.github/workflows/*.yml', '**/.github/workflows/*.yaml')
        VersionPatterns = @(
            @{
                Pattern     = 'uses:\s*([^@\s]+)@([^#\s]+)'
                Groups      = @{ Action = 1; Version = 2 }
                Description = 'GitHub Actions uses statements'
            }
        )
        SHAPattern      = '^[a-fA-F0-9]{40}$'
        RemediationUrl  = 'https://api.github.com/repos/{0}/commits/{1}'
    }

    'npm'            = @{
        FilePatterns    = @('**/package.json')
        ExcludePatterns = @('node_modules')
        ValidationFunc  = 'Get-NpmDependencyViolations'
        RemediationUrl  = 'https://registry.npmjs.org/{0}/{1}'
    }

    'pip'              = @{
        FilePatterns    = @('**/requirements*.txt', '**/Pipfile', '**/pyproject.toml', '**/setup.py')
        ExcludePatterns = @('.venv', 'venv', '.tox', '.nox', '__pypackages__')
        VersionPatterns = @(
            @{
                Pattern     = '([a-zA-Z0-9\-_]+)==([^#\s]+)'
                Groups      = @{ Package = 1; Version = 2 }
                Description = 'Python pip requirements'
            }
        )
        SHAPattern      = '^[a-fA-F0-9]{40}$'
        RemediationUrl  = 'https://pypi.org/pypi/{0}/{1}/json'
    }

    'shell-downloads'  = @{
        FilePatterns    = @('**/.devcontainer/scripts/*.sh', '**/scripts/*.sh')
        ExcludePatterns = @('Fixtures')
        ValidationFunc  = 'Test-ShellDownloadSecurity'
        Description     = 'Shell script downloads must include checksum verification'
    }

    'workflow-npm-commands' = @{
        FilePatterns   = @('**/.github/workflows/*.yml', '**/.github/workflows/*.yaml')
        ValidationFunc = 'Get-WorkflowNpmCommandViolations'
        Description    = 'Workflow npm install/update commands should use npm ci'
    }
}

# DependencyViolation and ComplianceReport classes moved to ./Modules/SecurityClasses.psm1

#region Functions

function Test-NpmCommandLine {
    <#
    .SYNOPSIS
        Tests whether a line contains an unpinned npm command.
    .DESCRIPTION
        Matches npm install, npm i, npm update, and npm install-test commands.
        Does not match npm ci, npm run, npm test, npm audit, or npx.
    .PARAMETER Line
        The text line to test for npm commands.
    .OUTPUTS
        System.String or $null
    #>
    param(
        [Parameter(Mandatory)]
        [string]$Line
    )

    if ($Line -match '\bnpm\s+(install-test|install|update)\b') {
        return $Matches[0]
    }
    if ($Line -match '\bnpm\s+i\b(?!nstall|nit)') {
        return $Matches[0]
    }

    return $null
}

function New-NpmCommandViolation {
    <#
    .SYNOPSIS
        Creates a DependencyViolation for an unpinned npm command.
    .DESCRIPTION
        Constructs a DependencyViolation object with standard fields for
        npm command violations detected in workflow run: steps.
    .PARAMETER FileInfo
        Hashtable with Path, Type, and RelativePath keys.
    .PARAMETER LineNumber
        1-based line number of the violation.
    .PARAMETER Line
        The source line containing the npm command.
    .PARAMETER Command
        The matched npm command string.
    .OUTPUTS
        DependencyViolation
    #>
    param(
        [Parameter(Mandatory)]
        [hashtable]$FileInfo,
        [Parameter(Mandatory)]
        [int]$LineNumber,
        [Parameter(Mandatory)]
        [string]$Line,
        [Parameter(Mandatory)]
        [string]$Command
    )

    $violation = [DependencyViolation]::new(
        $FileInfo.RelativePath,
        $LineNumber,
        'workflow-npm-commands',
        $Command,
        'Medium',
        "Unpinned npm command detected: '$Command'. Use 'npm ci' for deterministic installs from lockfile."
    )
    $violation.ViolationType = 'Unpinned'
    $violation.CurrentRef = $Line.Trim()
    $violation.Remediation = "Replace '$Command' with 'npm ci' for reproducible builds."
    return $violation
}

function Get-WorkflowNpmCommandViolations {
    <#
    .SYNOPSIS
        Detects unpinned npm install commands in GitHub Actions workflow run: steps.
    .DESCRIPTION
        Scans workflow YAML files for run: blocks and detects npm commands that
        modify the dependency tree (install, i, update, install-test). Commands
        that use the lockfile deterministically (ci) or do not install packages
        (run, test, audit) are not flagged.

        Uses indentation-aware parsing to confine detection to actual run: block
        content, reducing false positives from YAML comments or unrelated keys.
    .PARAMETER FileInfo
        Hashtable with Path, Type, and RelativePath keys identifying the file to scan.
    .OUTPUTS
        DependencyViolation[]
    #>
    param(
        [Parameter(Mandatory)]
        [hashtable]$FileInfo
    )

    $violations = @()
    $totalNpmCommands = 0
    $filePath = $FileInfo.Path

    if (-not (Test-Path -LiteralPath $filePath)) {
        return @{ TotalCount = 0; Violations = @() }
    }

    $lines = Get-Content -LiteralPath $filePath
    $inRunBlock = $false
    $runBlockIndent = 0

    for ($i = 0; $i -lt $lines.Count; $i++) {
        $line = $lines[$i]
        $trimmed = $line.TrimStart()

        if ($trimmed -eq '' -or $trimmed.StartsWith('#')) {
            continue
        }

        $currentIndent = $line.Length - $line.TrimStart().Length

        if ($trimmed -match '^run:\s*(.*)$') {
            $runContent = $Matches[1].Trim()
            $runBlockIndent = $currentIndent

            if ($runContent -and $runContent -notmatch '^[|>]') {
                $npmMatch = Test-NpmCommandLine -Line $runContent
                if ($npmMatch) {
                    $totalNpmCommands++
                    $violations += New-NpmCommandViolation -FileInfo $FileInfo -LineNumber ($i + 1) -Line $runContent -Command $npmMatch
                }
                $inRunBlock = $false
            } else {
                $inRunBlock = $true
            }
            continue
        }

        if ($inRunBlock) {
            if ($currentIndent -le $runBlockIndent) {
                $inRunBlock = $false
                if ($trimmed -match '^run:\s*(.*)$') {
                    $i--
                    continue
                }
            } else {
                if ($trimmed.StartsWith('#')) {
                    continue
                }
                $npmMatch = Test-NpmCommandLine -Line $trimmed
                if ($npmMatch) {
                    $totalNpmCommands++
                    $violations += New-NpmCommandViolation -FileInfo $FileInfo -LineNumber ($i + 1) -Line $trimmed -Command $npmMatch
                }
            }
        }
    }

    return @{ TotalCount = $totalNpmCommands; Violations = $violations }
}

function Test-ShellDownloadSecurity {
    <#
    .SYNOPSIS
        Scans shell scripts for curl/wget downloads lacking checksum verification.

    .DESCRIPTION
        Analyzes shell scripts to detect download commands (curl/wget) that do not
        have corresponding checksum verification (sha256sum/shasum) within the
        following lines.

    .PARAMETER FileInfo
        Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$FileInfo
    )

    $FilePath = $FileInfo.Path

    if (-not (Test-Path $FilePath)) {
        return @{ TotalCount = 0; Violations = @() }
    }

    $lines = Get-Content $FilePath
    $violations = @()
    $totalDownloads = 0

    # Pattern to match curl/wget download commands
    $downloadPattern = '(curl|wget)\s+.*https?://[^\s]+'
    $checksumPattern = 'sha256sum|shasum|Get-FileHash|openssl\s+dgst\s+-sha256|sha256sum\s+-c'

    for ($i = 0; $i -lt $lines.Count; $i++) {
        $line = $lines[$i]
        if ($line -match $downloadPattern) {
            $totalDownloads++
            # Check next 5 lines for checksum verification
            $hasChecksum = $false
            $searchEnd = [Math]::Min($i + 5, $lines.Count - 1)

            for ($j = $i; $j -le $searchEnd; $j++) {
                if ($lines[$j] -match $checksumPattern) {
                    $hasChecksum = $true
                    break
                }
            }

            if (-not $hasChecksum) {
                $violation = [DependencyViolation]::new()
                $violation.File = $FileInfo.RelativePath
                $violation.Line = $i + 1
                $violation.Type = $FileInfo.Type
                $violation.Name = $line.Trim()
                $violation.Severity = 'Medium'
                $violation.ViolationType = 'Unpinned'
                $violation.Description = 'Download without checksum verification'
                $violation.Metadata = @{ Pattern = $line.Trim() }
                $violations += $violation
            }
        }
    }

    return @{ TotalCount = $totalDownloads; Violations = $violations }
}

function Get-NpmDependencyViolations {
    <#
    .SYNOPSIS
        Analyzes package.json files for unpinned npm dependencies.
    .DESCRIPTION
        Parses package.json as JSON and checks dependency sections
        (dependencies, devDependencies, peerDependencies, optionalDependencies)
        for exact version pinning. Versions must be exact semver (e.g. 1.2.3)
        without range operators like ^, ~, *, >=, ||, or URL/git references.
    .PARAMETER FileInfo
        Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan.
    .OUTPUTS
        Array of PSCustomObjects representing dependency violations.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$FileInfo
    )

    $filePath = $FileInfo.Path
    $relativePath = $FileInfo.RelativePath
    $type = $FileInfo.Type
    $violations = @()
    $totalCount = 0

    if (-not (Test-Path -Path $filePath -PathType Leaf)) {
        return @{ TotalCount = 0; Violations = @() }
    }

    try {
        $content = Get-Content -Path $filePath -Raw -ErrorAction Stop
        $packageJson = $content | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        Write-Warning "Failed to parse $relativePath as JSON: $_"
        return @{ TotalCount = 0; Violations = @() }
    }

    # Build a line-number lookup from raw file content
    $lines = Get-Content -Path $filePath -ErrorAction SilentlyContinue

    $dependencySections = @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies')

    foreach ($section in $dependencySections) {
        $deps = $packageJson.$section
        if ($null -eq $deps) {
            continue
        }

        foreach ($prop in $deps.PSObject.Properties) {
            $packageName = $prop.Name
            $version = $prop.Value

            if ([string]::IsNullOrWhiteSpace($version)) {
                continue
            }

            $totalCount++
            $isPinned = Test-NpmExactVersion -Version $version

            if (-not $isPinned) {
                # Find the line number by searching for the package name in the file
                $lineNumber = 1
                if ($null -ne $lines) {
                    $escapedName = [regex]::Escape($packageName)
                    for ($i = 0; $i -lt $lines.Count; $i++) {
                        if ($lines[$i] -match """$escapedName""\s*:") {
                            $lineNumber = $i + 1
                            break
                        }
                    }
                }

                $violation = [DependencyViolation]::new()
                $violation.File = $relativePath
                $violation.Line = $lineNumber
                $violation.Type = $type
                $violation.Name = $packageName
                $violation.Version = $version
                $violation.Severity = 'Medium'
                $violation.ViolationType = 'Unpinned'
                $violation.Description = "Unpinned npm dependency in $section"
                $violation.Metadata = @{ Section = $section }
                $violations += $violation
            }
        }
    }

    return @{ TotalCount = $totalCount; Violations = $violations }
}

function Test-NpmExactVersion {
    <#
    .SYNOPSIS
        Tests whether an npm version string is an exact pinned version.
    .DESCRIPTION
        Returns $true for exact semver versions (e.g. 1.2.3, 1.0.0-beta.1).
        Returns $false for ranges, wildcards, URLs, tags, and git references.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Version
    )

    # Reject range operators, wildcards, URLs, git refs, and tags like "latest"
    if ($Version -match '^[~^>=<*|]' -or
        $Version -match '://' -or
        $Version -match '\.git\b' -or
        $Version -match '\s*\|\|' -or
        $Version -match '^\w+$' -and $Version -notmatch '^\d') {
        return $false
    }

    # Accept exact semver: major.minor.patch with optional prerelease/build metadata
    return $Version -match '^\d+\.\d+\.\d+(-[a-zA-Z0-9._-]+)?(\+[a-zA-Z0-9._-]+)?$'
}

function Get-FilesToScan {
    <#
    .SYNOPSIS
    Discovers files to scan based on dependency type patterns.
    #>
    [CmdletBinding()]
    param(
        [string]$ScanPath,
        [string[]]$Types,
        [string[]]$ExcludePatterns
    )

    $allFiles = @()

    foreach ($type in $Types) {
        if ($DependencyPatterns.ContainsKey($type)) {
            $patterns = $DependencyPatterns[$type].FilePatterns

            foreach ($pattern in $patterns) {
                try {
                    # Decompose glob into a directory prefix and a leaf filename filter.
                    # Get-ChildItem -Path does not expand ** globs on all platforms,
                    # so we strip the ** segments and use -Recurse with -Filter instead.
                    $segments = $pattern -split '[/\\]'
                    $leafFilter = $segments[-1]
                    $dirSegments = $segments[0..($segments.Length - 2)] | Where-Object { $_ -ne '**' }

                    if ($dirSegments.Count -gt 0) {
                        $basePath = Join-Path $ScanPath ($dirSegments -join [System.IO.Path]::DirectorySeparatorChar)
                    }
                    else {
                        $basePath = $ScanPath
                    }

                    if (-not (Test-Path -Path $basePath -PathType Container)) {
                        continue
                    }

                    $files = Get-ChildItem -Path $basePath -Filter $leafFilter -Recurse -File -ErrorAction SilentlyContinue

                    # Merge type-specific exclude patterns with caller-provided patterns
                    $mergedExcludes = @()
                    if ($ExcludePatterns) {
                        $mergedExcludes += @($ExcludePatterns)
                    }
                    if ($DependencyPatterns[$type].ContainsKey('ExcludePatterns')) {
                        $mergedExcludes += $DependencyPatterns[$type].ExcludePatterns
                    }

                    if ($mergedExcludes) {
                        foreach ($exclude in $mergedExcludes) {
                            $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
                        }
                    }

                    $allFiles += $files | ForEach-Object {
                        @{
                            Path         = $_.FullName
                            Type         = $type
                            RelativePath = [System.IO.Path]::GetRelativePath($ScanPath, $_.FullName)
                        }
                    }
                }
                catch {
                    Write-SecurityLog -CIAnnotation "Error scanning for $type files with pattern $pattern`: $($_.Exception.Message)" -Level Warning
                }
            }
        }
    }

    return $allFiles | Sort-Object Path -Unique
}

function Test-SHAPinning {
    <#
    .SYNOPSIS
    Tests if a version reference is properly SHA-pinned.
    #>
    [CmdletBinding()]
    param(
        [string]$Version,
        [string]$Type
    )

    if ($DependencyPatterns.ContainsKey($Type) -and $DependencyPatterns[$Type].SHAPattern) {
        $shaPattern = $DependencyPatterns[$Type].SHAPattern
        return $Version -match $shaPattern
    }

    return $false
}

function Get-DependencyViolation {
    <#
    .SYNOPSIS
    Scans a file for dependency pinning violations.
    #>
    [CmdletBinding()]
    param(
        [hashtable]$FileInfo
    )

    $violations = @()
    $filePath = $FileInfo.Path
    $fileType = $FileInfo.Type

    if (!(Test-Path $filePath)) {
        return @{ TotalCount = 0; Violations = @() }
    }

    # Check if this type uses a validation function instead of regex patterns
    if ($null -ne $DependencyPatterns[$fileType].ValidationFunc) {
        $funcName = $DependencyPatterns[$fileType].ValidationFunc
        $scanResult = & $funcName -FileInfo $FileInfo

        if ($null -eq $scanResult) {
            return @{ TotalCount = 0; Violations = @() }
        }

        foreach ($v in @($scanResult.Violations)) {
            if ($null -eq $v) {
                continue
            }

            if (-not ($v -is [DependencyViolation])) {
                $actualType = $v.GetType().FullName
                throw "Validation function '$funcName' must return [DependencyViolation] objects, got '$actualType'."
            }

            if (-not $v.File) {
                $v.File = $FileInfo.RelativePath
            }

            if ($v.Line -lt 1) {
                $v.Line = 1
            }

            if (-not $v.Type) {
                $v.Type = $fileType
            }
        }

        return $scanResult
    }

    try {
        $content = Get-Content -Path $filePath -Raw
        $lines = Get-Content -Path $filePath

        $patterns = $DependencyPatterns[$fileType].VersionPatterns
        $totalCount = 0

        foreach ($patternInfo in $patterns) {
            $pattern = $patternInfo.Pattern
            $description = $patternInfo.Description

            $regexMatches = [regex]::Matches($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
            $totalCount += @($regexMatches).Count

            foreach ($match in $regexMatches) {
                # Find line number
                $lineNumber = 1
                $position = $match.Index
                for ($i = 0; $i -lt $position; $i++) {
                    if ($content[$i] -eq "`n") {
                        $lineNumber++
                    }
                }

                # Extract dependency information
                $dependencyName = $match.Groups[1].Value
                $version = $match.Groups[2].Value

                # Check if properly pinned
                if (!(Test-SHAPinning -Version $version -Type $fileType)) {
                    $violation = [DependencyViolation]::new()
                    $violation.File = $FileInfo.RelativePath
                    $violation.Line = $lineNumber
                    $violation.Type = $fileType
                    $violation.Name = $dependencyName
                    $violation.Version = $version
                    $violation.CurrentRef = $match.Value
                    $violation.Description = "Unpinned dependency: $description"
                    $violation.Severity = if ($fileType -eq 'github-actions') { 'High' } else { 'Medium' }
                    $violation.ViolationType = 'Unpinned'
                    $violation.Metadata['PatternDescription'] = $description
                    $violation.Metadata['LineContent'] = $lines[$lineNumber - 1]

                    $violations += $violation
                }
            }
        }
    }
    catch {
        Write-SecurityLog -CIAnnotation "Error scanning file $filePath`: $($_.Exception.Message)" -Level Warning
    }

    return @{ TotalCount = $totalCount; Violations = $violations }
}

function Get-RemediationSuggestion {
    <#
    .SYNOPSIS
    Generates remediation suggestions for unpinned dependencies.
    #>
    [CmdletBinding()]
    param(
        [DependencyViolation]$Violation,
        
        [switch]$Remediate
    )

    $type = $Violation.Type
    $name = $Violation.Name
    $version = $Violation.Version

    if (!$Remediate) {
        return "Enable -Remediate flag for specific SHA suggestions"
    }

    try {
        switch ($type) {
            'github-actions' {
                # For GitHub Actions, resolve tag to commit SHA
                $apiUrl = "https://api.github.com/repos/$name/commits/$version"
                $headers = @{}

                if ($env:GITHUB_TOKEN) {
                    $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN"
                }

                $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -TimeoutSec 30
                $sha = $response.sha

                if ($sha) {
                    return "Pin to SHA: uses: $name@$sha # $version"
                }
            }

            default {
                return "Research and pin to specific commit SHA or content hash for $type dependencies"
            }
        }
    }
    catch {
        Write-SecurityLog -CIAnnotation "Could not generate automatic remediation for $($Violation.Name): $($_.Exception.Message)" -Level Warning
    }

    return "Manually research and pin to immutable reference"
}

function Get-ComplianceReportData {
    <#
    .SYNOPSIS
    Generates a comprehensive compliance report.
    #>
    [CmdletBinding()]
    param(
        [DependencyViolation[]]$Violations,
        [hashtable[]]$ScannedFiles,
        [string]$ScanPath,
        [Parameter(Mandatory)]
        [int]$TotalDependencies,
        [switch]$Remediate
    )

    $report = [ComplianceReport]::new()
    $report.ScanPath = $ScanPath
    $report.ScannedFiles = $ScannedFiles.Count
    $report.Violations = $Violations

    # Calculate metrics using true dependency counts from scanners
    $report.TotalDependencies = $TotalDependencies
    $report.UnpinnedDependencies = @($Violations).Count
    $report.PinnedDependencies = $TotalDependencies - $report.UnpinnedDependencies
    $report.CalculateScore()

    # Generate summary by type
    $report.Summary = @{}
    foreach ($type in @($Violations | Group-Object Type)) {
        $report.Summary[$type.Name] = @{
            Total  = $type.Count
            High   = @($type.Group | Where-Object { $_.Severity -eq 'High' }).Count
            Medium = @($type.Group | Where-Object { $_.Severity -eq 'Medium' }).Count
            Low    = @($type.Group | Where-Object { $_.Severity -eq 'Low' }).Count
        }
    }

    # Add metadata
    $report.Metadata = @{
        PowerShellVersion  = $PSVersionTable.PSVersion.ToString()
        Platform           = $PSVersionTable.Platform
        ScanTimestamp      = $report.Timestamp.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
        IncludedTypes      = $IncludeTypes
        ExcludedPaths      = $ExcludePaths
        RemediationEnabled = $Remediate.IsPresent
        ComplianceThreshold = $Threshold
    }

    return $report
}

function Export-ComplianceReport {
    <#
    .SYNOPSIS
    Exports compliance report in specified format.
    #>
    [CmdletBinding()]
    param(
        # Use duck typing to avoid class type collision during code coverage instrumentation
        $Report,
        [string]$Format,
        [string]$OutputPath
    )

    # Validate required properties on duck-typed $Report parameter (ComplianceReport schema)
    $requiredProperties = @('ComplianceScore', 'Violations', 'TotalDependencies', 'UnpinnedDependencies', 'Metadata')
    foreach ($prop in $requiredProperties) {
        if ($null -eq $Report.PSObject.Properties[$prop]) {
            throw "Report object missing required property: $prop"
        }
    }

    # Ensure parent directory exists
    $parentDir = Split-Path -Path $OutputPath -Parent
    if ($parentDir -and -not (Test-Path $parentDir)) {
        New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
    }

    switch ($Format.ToLower()) {
        'json' {
            $Report | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8
        }

        'sarif' {
            $sarif = @{
                version    = "2.1.0"
                "`$schema" = "https://json.schemastore.org/sarif-2.1.0.json"
                runs       = @(@{
                        tool    = @{
                            driver = @{
                                name           = "dependency-pinning-analyzer"
                                version        = "1.0.0"
                                informationUri = "https://github.com/microsoft/hve-core"
                            }
                        }
                        results = @($Report.Violations | ForEach-Object {
                                @{
                                    ruleId     = "dependency-not-pinned"
                                    level      = switch ($_.Severity) { 'High' { 'error' } 'Medium' { 'warning' } default { 'note' } }
                                    message    = @{ text = $_.Description }
                                    locations  = @(@{
                                            physicalLocation = @{
                                                artifactLocation = @{ uri = $_.File }
                                                region           = @{ startLine = $_.Line }
                                            }
                                        })
                                    properties = @{
                                        dependencyName = $_.Name
                                        currentVersion = $_.Version
                                        remediation    = $_.Remediation
                                    }
                                }
                            })
                    })
            }
            $sarif | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8
        }

        'csv' {
            $Report.Violations | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
        }

        'markdown' {
            $markdown = @"
# Dependency Pinning Compliance Report

**Scan Date:** $($Report.Timestamp.ToString('yyyy-MM-dd HH:mm:ss'))
**Scan Path:** $($Report.ScanPath)
**Compliance Score:** $($Report.ComplianceScore)%

## Summary

| Metric | Count |
|--------|--------|
| Total Files Scanned | $($Report.ScannedFiles) |
| Total Dependencies | $($Report.TotalDependencies) |
| Pinned Dependencies | $($Report.PinnedDependencies) |
| Unpinned Dependencies | $($Report.UnpinnedDependencies) |

## Violations by Type

"@
            foreach ($type in $Report.Summary.Keys) {
                $summary = $Report.Summary[$type]
                $markdown += @"

### $type
- **Total:** $($summary.Total)
- **High Severity:** $($summary.High)
- **Medium Severity:** $($summary.Medium)
- **Low Severity:** $($summary.Low)

"@
            }

            if ($Report.Violations.Count -gt 0) {
                $markdown += @"

## Detailed Violations

| File | Line | Type | Dependency | Current Version | Severity | Remediation |
|------|------|------|------------|----------------|----------|-------------|
"@
                foreach ($violation in $Report.Violations) {
                    $markdown += "|$($violation.File)|$($violation.Line)|$($violation.Type)|$($violation.Name)|$($violation.Version)|$($violation.Severity)|$($violation.Remediation)|`n"
                }
            }

            $markdown | Out-File -FilePath $OutputPath -Encoding UTF8
        }

        'table' {
            # Display formatted table to console and save simple text format
            if ($Report.Violations.Count -gt 0) {
                $Report.Violations | Format-Table -Property File, Line, Type, Name, Version, Severity -AutoSize | Out-File -FilePath $OutputPath -Encoding UTF8 -Width 200
            }
            else {
                "No dependency pinning violations found." | Out-File -FilePath $OutputPath -Encoding UTF8
            }
        }
    }

    Write-SecurityLog -CIAnnotation "Compliance report exported to: $OutputPath" -Level Success
}

function Export-CICDArtifact {
    <#
    .SYNOPSIS
    Exports compliance report as CI/CD artifacts for both GitHub Actions and Azure DevOps.
    #>
    [CmdletBinding()]
    param(
        [ComplianceReport]$Report,
        [string]$ReportPath
    )

    Write-SecurityLog -CIAnnotation "Preparing compliance artifacts for CI/CD systems..." -Level Info

    $platform = Get-CIPlatform
    Write-SecurityLog -CIAnnotation "Detected $platform environment - setting up artifacts" -Level Info

    # Set CI outputs (works for both GitHub Actions and Azure DevOps)
    Set-CIOutput -Name 'dependency-report' -Value $ReportPath -IsOutput
    Set-CIOutput -Name 'compliance-score' -Value $Report.ComplianceScore -IsOutput
    Set-CIOutput -Name 'unpinned-count' -Value $Report.UnpinnedDependencies -IsOutput

    # Create summary content
    $summaryContent = @"
# 📌 Dependency Pinning Analysis

**Compliance Score:** $($Report.ComplianceScore)%
**Unpinned Dependencies:** $($Report.UnpinnedDependencies)
**Total Dependencies Scanned:** $($Report.TotalDependencies)

$(if ($Report.UnpinnedDependencies -gt 0) { "⚠️ **Action Required:** $($Report.UnpinnedDependencies) dependencies are not properly pinned to immutable references." } else { "✅ **All Clear:** All dependencies are properly pinned!" })
"@

    # Write step summary
    Write-CIStepSummary -Content $summaryContent

    # Publish artifact
    Publish-CIArtifact -Path $ReportPath -Name 'dependency-pinning-report' -ContainerFolder 'dependency-pinning'

    # Set up local artifact directory for GitHub Actions upload-artifact action
    if ($platform -eq 'github') {
        $artifactDir = Join-Path $PWD "dependency-pinning-artifacts"
        New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null
        Copy-Item -Path $ReportPath -Destination $artifactDir -Force
    }

    Write-SecurityLog -CIAnnotation "Compliance artifacts prepared for CI/CD consumption" -Level Success
}

function Invoke-DependencyPinningAnalysis {
    <#
    .SYNOPSIS
        Orchestrates dependency pinning compliance analysis.
    #>
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter()]
        [string]$Path = ".",

        [Parameter()]
        [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads,workflow-npm-commands",

        [Parameter()]
        [string]$ExcludePaths = "",

        [Parameter()]
        [string]$Format = 'json',

        [Parameter()]
        [string]$OutputPath = 'logs/dependency-pinning-results.json',

        [Parameter()]
        [switch]$FailOnUnpinned,

        [Parameter()]
        [int]$Threshold = 95,

        [Parameter()]
        [switch]$Remediate
    )

    Write-SecurityLog -CIAnnotation "Starting dependency pinning compliance analysis..." -Level Info
    Write-SecurityLog -CIAnnotation "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info
    Write-SecurityLog -CIAnnotation "Platform: $($PSVersionTable.Platform)" -Level Info

    # Parse include types and exclude paths
    $typesToCheck = $IncludeTypes.Split(',') | ForEach-Object { $_.Trim() }
    $excludePatterns = if ($ExcludePaths) { $ExcludePaths.Split(',') | ForEach-Object { $_.Trim() } } else { @() }

    Write-SecurityLog -CIAnnotation "Scanning path: $Path" -Level Info
    Write-SecurityLog -CIAnnotation "Include types: $($typesToCheck -join ', ')" -Level Info
    if ($excludePatterns) { Write-SecurityLog -CIAnnotation "Exclude patterns: $($excludePatterns -join ', ')" -Level Info }

    # Discover files to scan
    $filesToScan = @(Get-FilesToScan -ScanPath $Path -Types $typesToCheck -ExcludePatterns $excludePatterns)
    Write-SecurityLog -CIAnnotation "Found $(@($filesToScan).Count) files to scan" -Level Info

    # Scan for violations
    $allViolations = @()
    $totalDependencyCount = 0
    foreach ($fileInfo in $filesToScan) {
        Write-SecurityLog -CIAnnotation "Scanning: $($fileInfo.RelativePath)" -Level Info
        $scanResult = Get-DependencyViolation -FileInfo $fileInfo
        $totalDependencyCount += $scanResult.TotalCount
        $violations = @($scanResult.Violations)

        # Add remediation suggestions
        foreach ($violation in $violations) {
            $violation.Remediation = Get-RemediationSuggestion -Violation $violation -Remediate:$Remediate
        }

        $allViolations += $violations
    }

    Write-SecurityLog -CIAnnotation "Found $(@($allViolations).Count) dependency pinning violations" -Level Info

    # Emit per-violation CI annotations and console output
    if ($allViolations.Count -gt 0) {
        Write-Host "`n❌ Found $($allViolations.Count) unpinned dependencies:" -ForegroundColor Red
        $groupedByFile = $allViolations | Group-Object -Property File
        foreach ($fileGroup in $groupedByFile) {
            Write-Host "`n📄 $($fileGroup.Name)" -ForegroundColor Cyan
            foreach ($dep in $fileGroup.Group) {
                $annotationLevel = switch ($dep.Severity) {
                    'High'   { 'Error' }
                    'Medium' { 'Warning' }
                    default  { 'Notice' }
                }
                $icon = switch ($dep.Severity) {
                    'High'   { '❌' }
                    'Medium' { '⚠️' }
                    default  { 'ℹ️' }
                }
                $color = switch ($dep.Severity) {
                    'High'   { 'Red' }
                    'Medium' { 'Yellow' }
                    default  { 'Cyan' }
                }
                Write-Host "  $icon [$($dep.Severity)] $($dep.Name)@$($dep.Version): $($dep.Description) (Line $($dep.Line))" -ForegroundColor $color
                Write-CIAnnotation `
                    -Message "[$($dep.ViolationType)] $($dep.Name): $($dep.Description)" `
                    -Level $annotationLevel `
                    -File $dep.File `
                    -Line $dep.Line
            }
        }
    }
    else {
        Write-Host "`n✅ All dependencies are properly pinned." -ForegroundColor Green
    }

    # Generate compliance report
    $report = Get-ComplianceReportData -Violations $allViolations -ScannedFiles $filesToScan -ScanPath $Path -TotalDependencies $totalDependencyCount -Remediate:$Remediate

    # Export report
    Export-ComplianceReport -Report $report -Format $Format -OutputPath $OutputPath

    # Export CI/CD artifacts
    Export-CICDArtifact -Report $report -ReportPath $OutputPath

    # Display summary
    Write-SecurityLog -CIAnnotation "Compliance Analysis Complete!" -Level Success
    Write-SecurityLog -CIAnnotation "Compliance Score: $($report.ComplianceScore)%" -Level Info
    Write-SecurityLog -CIAnnotation "Total Dependencies: $($report.TotalDependencies)" -Level Info
    Write-SecurityLog -CIAnnotation "Unpinned Dependencies: $($report.UnpinnedDependencies)" -Level Info

    if ($report.UnpinnedDependencies -gt 0) {
        Write-SecurityLog -CIAnnotation "$($report.UnpinnedDependencies) dependencies require pinning for security compliance" -Level Warning

        # Check threshold compliance
        if ($report.ComplianceScore -lt $Threshold) {
            Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% is below threshold $Threshold%" -Level Error

            if ($FailOnUnpinned) {
                Write-SecurityLog -CIAnnotation "Failing build due to compliance threshold violation (-FailOnUnpinned enabled)" -Level Error
                throw "Compliance score $($report.ComplianceScore)% is below threshold $Threshold% (-FailOnUnpinned enabled)"
            }
            else {
                Write-SecurityLog -CIAnnotation "Threshold violation detected but continuing (soft-fail mode)" -Level Warning
            }
        }
        else {
            Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% meets threshold $Threshold%" -Level Info
        }
    }
    else {
        Write-SecurityLog -CIAnnotation "All dependencies are properly pinned! ✅ (100% compliance, exceeds $Threshold% threshold)" -Level Success
    }
}

#endregion Functions

#region Main Execution
if ($MyInvocation.InvocationName -ne '.') {
    try {
        Invoke-DependencyPinningAnalysis `
            -Path $Path `
            -IncludeTypes $IncludeTypes `
            -ExcludePaths $ExcludePaths `
            -Format $Format `
            -OutputPath $OutputPath `
            -FailOnUnpinned:$FailOnUnpinned `
            -Threshold $Threshold `
            -Remediate:$Remediate
        exit 0
    }
    catch {
        Write-Error -ErrorAction Continue "Test-DependencyPinning failed: $($_.Exception.Message)"
        Write-CIAnnotation -Message $_.Exception.Message -Level Error
        exit 1
    }
}
#endregion Main Execution