microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Test-DependencyPinning.ps1
927lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | #Requires -Version 7.0 |
| 5 | |
| 6 | <# |
| 7 | .SYNOPSIS |
| 8 | Verifies and reports on SHA pinning compliance for supply chain security. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | Cross-platform PowerShell script that analyzes GitHub Actions workflows, Docker images, |
| 12 | and other dependency declarations to verify compliance with SHA pinning security practices. |
| 13 | Identifies unpinned dependencies and provides remediation guidance. |
| 14 | |
| 15 | .PARAMETER Path |
| 16 | Root path to scan for dependency files. Defaults to current directory. |
| 17 | |
| 18 | .PARAMETER Recursive |
| 19 | Scan recursively through subdirectories. Default is true. |
| 20 | |
| 21 | .PARAMETER Format |
| 22 | Output format for compliance report. Options: json, sarif, csv, markdown, table. |
| 23 | Default is 'json' for programmatic processing. |
| 24 | |
| 25 | .PARAMETER OutputPath |
| 26 | Path where compliance results should be saved. Defaults to 'dependency-pinning-report.json' |
| 27 | in the current directory. |
| 28 | |
| 29 | .PARAMETER FailOnUnpinned |
| 30 | Exit with error code if pinning violations are found. Default is false for reporting mode. |
| 31 | |
| 32 | .PARAMETER ExcludePaths |
| 33 | Comma-separated list of paths to exclude from scanning (glob patterns supported). |
| 34 | |
| 35 | .PARAMETER IncludeTypes |
| 36 | Comma-separated list of dependency types to check. Options: github-actions, npm, pip. |
| 37 | Default is all types. |
| 38 | |
| 39 | .PARAMETER Threshold |
| 40 | Minimum compliance score percentage required for passing grade (0-100). |
| 41 | Script will exit with code 1 if compliance falls below threshold when -FailOnUnpinned is set. |
| 42 | Default is 95%. |
| 43 | |
| 44 | .PARAMETER Remediate |
| 45 | Generate remediation suggestions with specific SHA pins for unpinned dependencies. |
| 46 | |
| 47 | .EXAMPLE |
| 48 | ./Test-DependencyPinning.ps1 |
| 49 | Scan current directory for dependency pinning compliance. |
| 50 | |
| 51 | .EXAMPLE |
| 52 | ./Test-DependencyPinning.ps1 -Path "/workspace" -Format "sarif" -FailOnUnpinned |
| 53 | Scan workspace directory, output SARIF format, fail on violations. |
| 54 | |
| 55 | .EXAMPLE |
| 56 | ./Test-DependencyPinning.ps1 -IncludeTypes "github-actions,pip" -Remediate |
| 57 | Check only GitHub Actions and pip dependencies with remediation suggestions. |
| 58 | |
| 59 | .EXAMPLE |
| 60 | ./Test-DependencyPinning.ps1 -Threshold 90 -FailOnUnpinned |
| 61 | Enforce 90% compliance threshold and fail build if not met. |
| 62 | |
| 63 | .EXAMPLE |
| 64 | ./Test-DependencyPinning.ps1 -Threshold 100 -IncludeTypes "github-actions" |
| 65 | Require 100% SHA pinning for GitHub Actions only. |
| 66 | |
| 67 | .EXAMPLE |
| 68 | ./Test-DependencyPinning.ps1 -Threshold 80 |
| 69 | Report compliance against 80% threshold but continue on violations. |
| 70 | |
| 71 | .NOTES |
| 72 | Requires: |
| 73 | - PowerShell 7.0 or later for cross-platform compatibility |
| 74 | - Internet connectivity for SHA resolution (with -Remediate) |
| 75 | - GitHub API access for action SHA resolution (optional) |
| 76 | |
| 77 | Compatible with: |
| 78 | - Windows PowerShell 5.1+ (limited cross-platform features) |
| 79 | - PowerShell 7.x on Windows, Linux, macOS |
| 80 | - GitHub Actions runners (ubuntu-latest, windows-latest, macos-latest) |
| 81 | - Azure DevOps agents (Microsoft-hosted and self-hosted) |
| 82 | |
| 83 | .LINK |
| 84 | https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions |
| 85 | #> |
| 86 | |
| 87 | # Import security classes from shared module |
| 88 | using module ./Modules/SecurityClasses.psm1 |
| 89 | |
| 90 | [CmdletBinding()] |
| 91 | param( |
| 92 | [Parameter(Mandatory = $false)] |
| 93 | [string]$Path = ".", |
| 94 | |
| 95 | [Parameter(Mandatory = $false)] |
| 96 | [switch]$Recursive, |
| 97 | |
| 98 | [Parameter(Mandatory = $false)] |
| 99 | [ValidateSet('json', 'sarif', 'csv', 'markdown', 'table')] |
| 100 | [string]$Format = 'json', |
| 101 | |
| 102 | [Parameter(Mandatory = $false)] |
| 103 | [string]$OutputPath = 'logs/dependency-pinning-results.json', |
| 104 | |
| 105 | [Parameter(Mandatory = $false)] |
| 106 | [switch]$FailOnUnpinned, |
| 107 | |
| 108 | [Parameter(Mandatory = $false)] |
| 109 | [string]$ExcludePaths = "", |
| 110 | |
| 111 | [Parameter(Mandatory = $false)] |
| 112 | [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads", |
| 113 | |
| 114 | [Parameter(Mandatory = $false)] |
| 115 | [ValidateRange(0, 100)] |
| 116 | [int]$Threshold = 95, |
| 117 | |
| 118 | [Parameter(Mandatory = $false)] |
| 119 | [switch]$Remediate |
| 120 | ) |
| 121 | |
| 122 | $ErrorActionPreference = 'Stop' |
| 123 | |
| 124 | # Import CIHelpers for workflow command escaping |
| 125 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 126 | |
| 127 | # Define dependency patterns for different ecosystems |
| 128 | $DependencyPatterns = @{ |
| 129 | 'github-actions' = @{ |
| 130 | FilePatterns = @('**/.github/workflows/*.yml', '**/.github/workflows/*.yaml') |
| 131 | VersionPatterns = @( |
| 132 | @{ |
| 133 | Pattern = 'uses:\s*([^@\s]+)@([^#\s]+)' |
| 134 | Groups = @{ Action = 1; Version = 2 } |
| 135 | Description = 'GitHub Actions uses statements' |
| 136 | } |
| 137 | ) |
| 138 | SHAPattern = '^[a-fA-F0-9]{40}$' |
| 139 | RemediationUrl = 'https://api.github.com/repos/{0}/commits/{1}' |
| 140 | } |
| 141 | |
| 142 | 'npm' = @{ |
| 143 | FilePatterns = @('**/package.json') |
| 144 | ValidationFunc = 'Get-NpmDependencyViolations' |
| 145 | SHAPattern = '^[a-fA-F0-9]{40}$' |
| 146 | RemediationUrl = 'https://registry.npmjs.org/{0}/{1}' |
| 147 | } |
| 148 | |
| 149 | 'pip' = @{ |
| 150 | FilePatterns = @('**/requirements*.txt', '**/Pipfile', '**/pyproject.toml', '**/setup.py') |
| 151 | VersionPatterns = @( |
| 152 | @{ |
| 153 | Pattern = '([a-zA-Z0-9\-_]+)==([^#\s]+)' |
| 154 | Groups = @{ Package = 1; Version = 2 } |
| 155 | Description = 'Python pip requirements' |
| 156 | } |
| 157 | ) |
| 158 | SHAPattern = '^[a-fA-F0-9]{40}$' |
| 159 | RemediationUrl = 'https://pypi.org/pypi/{0}/{1}/json' |
| 160 | } |
| 161 | |
| 162 | 'shell-downloads' = @{ |
| 163 | FilePatterns = @('**/.devcontainer/scripts/*.sh', '**/scripts/*.sh') |
| 164 | ValidationFunc = 'Test-ShellDownloadSecurity' |
| 165 | Description = 'Shell script downloads must include checksum verification' |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | # DependencyViolation and ComplianceReport classes moved to ./Modules/SecurityClasses.psm1 |
| 170 | |
| 171 | #region Functions |
| 172 | |
| 173 | function Test-ShellDownloadSecurity { |
| 174 | <# |
| 175 | .SYNOPSIS |
| 176 | Scans shell scripts for curl/wget downloads lacking checksum verification. |
| 177 | |
| 178 | .DESCRIPTION |
| 179 | Analyzes shell scripts to detect download commands (curl/wget) that do not |
| 180 | have corresponding checksum verification (sha256sum/shasum) within the |
| 181 | following lines. |
| 182 | |
| 183 | .PARAMETER FileInfo |
| 184 | Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan. |
| 185 | #> |
| 186 | [CmdletBinding()] |
| 187 | param( |
| 188 | [Parameter(Mandatory)] |
| 189 | [hashtable]$FileInfo |
| 190 | ) |
| 191 | |
| 192 | $FilePath = $FileInfo.Path |
| 193 | |
| 194 | if (-not (Test-Path $FilePath)) { |
| 195 | return @() |
| 196 | } |
| 197 | |
| 198 | $lines = Get-Content $FilePath |
| 199 | $violations = @() |
| 200 | |
| 201 | # Pattern to match curl/wget download commands |
| 202 | $downloadPattern = '(curl|wget)\s+.*https?://[^\s]+' |
| 203 | $checksumPattern = 'sha256sum|shasum|Get-FileHash|openssl\s+dgst\s+-sha256|sha256sum\s+-c' |
| 204 | |
| 205 | for ($i = 0; $i -lt $lines.Count; $i++) { |
| 206 | $line = $lines[$i] |
| 207 | if ($line -match $downloadPattern) { |
| 208 | # Check next 5 lines for checksum verification |
| 209 | $hasChecksum = $false |
| 210 | $searchEnd = [Math]::Min($i + 5, $lines.Count - 1) |
| 211 | |
| 212 | for ($j = $i; $j -le $searchEnd; $j++) { |
| 213 | if ($lines[$j] -match $checksumPattern) { |
| 214 | $hasChecksum = $true |
| 215 | break |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | if (-not $hasChecksum) { |
| 220 | $violation = [DependencyViolation]::new() |
| 221 | $violation.File = $FileInfo.RelativePath |
| 222 | $violation.Line = $i + 1 |
| 223 | $violation.Type = $FileInfo.Type |
| 224 | $violation.Name = $line.Trim() |
| 225 | $violation.Severity = 'warning' |
| 226 | $violation.Description = 'Download without checksum verification' |
| 227 | $violation.Metadata = @{ Pattern = $line.Trim() } |
| 228 | $violations += $violation |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | return $violations |
| 234 | } |
| 235 | |
| 236 | function Get-NpmDependencyViolations { |
| 237 | <# |
| 238 | .SYNOPSIS |
| 239 | Analyzes package.json files for unpinned npm dependencies. |
| 240 | .DESCRIPTION |
| 241 | Parses package.json as JSON and checks only actual dependency sections |
| 242 | (dependencies, devDependencies, peerDependencies, optionalDependencies) |
| 243 | for SHA-pinned versions. Ignores metadata fields like name, version, |
| 244 | description, contributes, scripts, repository, etc. |
| 245 | .PARAMETER FileInfo |
| 246 | Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan. |
| 247 | .OUTPUTS |
| 248 | Array of PSCustomObjects representing dependency violations. |
| 249 | #> |
| 250 | [CmdletBinding()] |
| 251 | param( |
| 252 | [Parameter(Mandatory)] |
| 253 | [hashtable]$FileInfo |
| 254 | ) |
| 255 | |
| 256 | $filePath = $FileInfo.Path |
| 257 | $relativePath = $FileInfo.RelativePath |
| 258 | $type = $FileInfo.Type |
| 259 | $violations = @() |
| 260 | |
| 261 | if (-not (Test-Path -Path $filePath -PathType Leaf)) { |
| 262 | return $violations |
| 263 | } |
| 264 | |
| 265 | try { |
| 266 | $content = Get-Content -Path $filePath -Raw -ErrorAction Stop |
| 267 | $packageJson = $content | ConvertFrom-Json -ErrorAction Stop |
| 268 | } |
| 269 | catch { |
| 270 | Write-Warning "Failed to parse $relativePath as JSON: $_" |
| 271 | return $violations |
| 272 | } |
| 273 | |
| 274 | $dependencySections = @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies') |
| 275 | |
| 276 | foreach ($section in $dependencySections) { |
| 277 | $deps = $packageJson.$section |
| 278 | if ($null -eq $deps) { |
| 279 | continue |
| 280 | } |
| 281 | |
| 282 | foreach ($prop in $deps.PSObject.Properties) { |
| 283 | $packageName = $prop.Name |
| 284 | $version = $prop.Value |
| 285 | |
| 286 | if ([string]::IsNullOrWhiteSpace($version)) { |
| 287 | continue |
| 288 | } |
| 289 | |
| 290 | $isPinned = Test-SHAPinning -Version $version -Type $type |
| 291 | |
| 292 | if (-not $isPinned) { |
| 293 | $violation = [DependencyViolation]::new() |
| 294 | $violation.File = $relativePath |
| 295 | $violation.Line = 0 |
| 296 | $violation.Type = $type |
| 297 | $violation.Name = $packageName |
| 298 | $violation.Version = $version |
| 299 | $violation.Severity = 'warning' |
| 300 | $violation.Description = "Unpinned npm dependency in $section" |
| 301 | $violation.Metadata = @{ Section = $section } |
| 302 | $violations += $violation |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | return $violations |
| 308 | } |
| 309 | |
| 310 | function Write-PinningLog { |
| 311 | [CmdletBinding()] |
| 312 | param( |
| 313 | [Parameter(Mandatory = $true)] |
| 314 | [string]$Message, |
| 315 | |
| 316 | [Parameter(Mandatory = $false)] |
| 317 | [ValidateSet('Info', 'Warning', 'Error', 'Success')] |
| 318 | [string]$Level = 'Info' |
| 319 | ) |
| 320 | |
| 321 | $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 322 | Write-Output "[$timestamp] [$Level] $Message" |
| 323 | } |
| 324 | |
| 325 | function Get-FilesToScan { |
| 326 | <# |
| 327 | .SYNOPSIS |
| 328 | Discovers files to scan based on dependency type patterns. |
| 329 | #> |
| 330 | [CmdletBinding()] |
| 331 | param( |
| 332 | [string]$ScanPath, |
| 333 | [string[]]$Types, |
| 334 | [string[]]$ExcludePatterns, |
| 335 | [switch]$Recursive |
| 336 | ) |
| 337 | |
| 338 | $allFiles = @() |
| 339 | |
| 340 | foreach ($type in $Types) { |
| 341 | if ($DependencyPatterns.ContainsKey($type)) { |
| 342 | $patterns = $DependencyPatterns[$type].FilePatterns |
| 343 | |
| 344 | foreach ($pattern in $patterns) { |
| 345 | # Convert glob pattern to PowerShell-compatible path |
| 346 | $searchPath = Join-Path $ScanPath $pattern |
| 347 | |
| 348 | try { |
| 349 | if ($Recursive) { |
| 350 | $files = Get-ChildItem -Path $searchPath -Recurse -File -ErrorAction SilentlyContinue |
| 351 | } |
| 352 | else { |
| 353 | $files = Get-ChildItem -Path $searchPath -File -ErrorAction SilentlyContinue |
| 354 | } |
| 355 | |
| 356 | # Apply exclusion filters |
| 357 | if ($ExcludePatterns) { |
| 358 | foreach ($exclude in $ExcludePatterns) { |
| 359 | $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" } |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | $allFiles += $files | ForEach-Object { |
| 364 | @{ |
| 365 | Path = $_.FullName |
| 366 | Type = $type |
| 367 | RelativePath = [System.IO.Path]::GetRelativePath($ScanPath, $_.FullName) |
| 368 | } |
| 369 | } |
| 370 | } |
| 371 | catch { |
| 372 | Write-PinningLog "Error scanning for $type files with pattern $pattern`: $($_.Exception.Message)" -Level Warning |
| 373 | } |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | return $allFiles | Sort-Object Path -Unique |
| 379 | } |
| 380 | |
| 381 | function Test-SHAPinning { |
| 382 | <# |
| 383 | .SYNOPSIS |
| 384 | Tests if a version reference is properly SHA-pinned. |
| 385 | #> |
| 386 | [CmdletBinding()] |
| 387 | param( |
| 388 | [string]$Version, |
| 389 | [string]$Type |
| 390 | ) |
| 391 | |
| 392 | if ($DependencyPatterns.ContainsKey($Type) -and $DependencyPatterns[$Type].SHAPattern) { |
| 393 | $shaPattern = $DependencyPatterns[$Type].SHAPattern |
| 394 | return $Version -match $shaPattern |
| 395 | } |
| 396 | |
| 397 | return $false |
| 398 | } |
| 399 | |
| 400 | function Get-DependencyViolation { |
| 401 | <# |
| 402 | .SYNOPSIS |
| 403 | Scans a file for dependency pinning violations. |
| 404 | #> |
| 405 | [CmdletBinding()] |
| 406 | param( |
| 407 | [hashtable]$FileInfo |
| 408 | ) |
| 409 | |
| 410 | $violations = @() |
| 411 | $filePath = $FileInfo.Path |
| 412 | $fileType = $FileInfo.Type |
| 413 | |
| 414 | if (!(Test-Path $filePath)) { |
| 415 | return $violations |
| 416 | } |
| 417 | |
| 418 | # Check if this type uses a validation function instead of regex patterns |
| 419 | if ($null -ne $DependencyPatterns[$fileType].ValidationFunc) { |
| 420 | $funcName = $DependencyPatterns[$fileType].ValidationFunc |
| 421 | $rawViolations = & $funcName -FileInfo $FileInfo |
| 422 | |
| 423 | if ($null -eq $rawViolations) { |
| 424 | return @() |
| 425 | } |
| 426 | |
| 427 | foreach ($v in $rawViolations) { |
| 428 | if ($null -eq $v) { |
| 429 | continue |
| 430 | } |
| 431 | |
| 432 | if (-not ($v -is [DependencyViolation])) { |
| 433 | $actualType = $v.GetType().FullName |
| 434 | throw "Validation function '$funcName' must return [DependencyViolation] objects, got '$actualType'." |
| 435 | } |
| 436 | |
| 437 | if (-not $v.File) { |
| 438 | $v.File = $FileInfo.RelativePath |
| 439 | } |
| 440 | |
| 441 | if ($v.Line -lt 1) { |
| 442 | $v.Line = 0 |
| 443 | } |
| 444 | |
| 445 | if (-not $v.Type) { |
| 446 | $v.Type = $fileType |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | return $rawViolations |
| 451 | } |
| 452 | |
| 453 | try { |
| 454 | $content = Get-Content -Path $filePath -Raw |
| 455 | $lines = Get-Content -Path $filePath |
| 456 | |
| 457 | $patterns = $DependencyPatterns[$fileType].VersionPatterns |
| 458 | |
| 459 | foreach ($patternInfo in $patterns) { |
| 460 | $pattern = $patternInfo.Pattern |
| 461 | $description = $patternInfo.Description |
| 462 | |
| 463 | $regexMatches = [regex]::Matches($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) |
| 464 | |
| 465 | foreach ($match in $regexMatches) { |
| 466 | # Find line number |
| 467 | $lineNumber = 1 |
| 468 | $position = $match.Index |
| 469 | for ($i = 0; $i -lt $position; $i++) { |
| 470 | if ($content[$i] -eq "`n") { |
| 471 | $lineNumber++ |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | # Extract dependency information |
| 476 | $dependencyName = $match.Groups[1].Value |
| 477 | $version = $match.Groups[2].Value |
| 478 | |
| 479 | # Check if properly pinned |
| 480 | if (!(Test-SHAPinning -Version $version -Type $fileType)) { |
| 481 | $violation = [DependencyViolation]::new() |
| 482 | $violation.File = $FileInfo.RelativePath |
| 483 | $violation.Line = $lineNumber |
| 484 | $violation.Type = $fileType |
| 485 | $violation.Name = $dependencyName |
| 486 | $violation.Version = $version |
| 487 | $violation.CurrentRef = $match.Value |
| 488 | $violation.Description = "Unpinned dependency: $description" |
| 489 | $violation.Severity = if ($fileType -eq 'github-actions') { 'High' } else { 'Medium' } |
| 490 | $violation.Metadata['PatternDescription'] = $description |
| 491 | $violation.Metadata['LineContent'] = $lines[$lineNumber - 1] |
| 492 | |
| 493 | $violations += $violation |
| 494 | } |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | catch { |
| 499 | Write-PinningLog "Error scanning file $filePath`: $($_.Exception.Message)" -Level Warning |
| 500 | } |
| 501 | |
| 502 | return $violations |
| 503 | } |
| 504 | |
| 505 | function Get-RemediationSuggestion { |
| 506 | <# |
| 507 | .SYNOPSIS |
| 508 | Generates remediation suggestions for unpinned dependencies. |
| 509 | #> |
| 510 | [CmdletBinding()] |
| 511 | param( |
| 512 | [DependencyViolation]$Violation, |
| 513 | |
| 514 | [switch]$Remediate |
| 515 | ) |
| 516 | |
| 517 | $type = $Violation.Type |
| 518 | $name = $Violation.Name |
| 519 | $version = $Violation.Version |
| 520 | |
| 521 | if (!$Remediate) { |
| 522 | return "Enable -Remediate flag for specific SHA suggestions" |
| 523 | } |
| 524 | |
| 525 | try { |
| 526 | switch ($type) { |
| 527 | 'github-actions' { |
| 528 | # For GitHub Actions, resolve tag to commit SHA |
| 529 | $apiUrl = "https://api.github.com/repos/$name/commits/$version" |
| 530 | $headers = @{} |
| 531 | |
| 532 | if ($env:GITHUB_TOKEN) { |
| 533 | $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" |
| 534 | } |
| 535 | |
| 536 | $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -TimeoutSec 30 |
| 537 | $sha = $response.sha |
| 538 | |
| 539 | if ($sha) { |
| 540 | return "Pin to SHA: uses: $name@$sha # $version" |
| 541 | } |
| 542 | } |
| 543 | |
| 544 | default { |
| 545 | return "Research and pin to specific commit SHA or content hash for $type dependencies" |
| 546 | } |
| 547 | } |
| 548 | } |
| 549 | catch { |
| 550 | Write-PinningLog "Could not generate automatic remediation for $($Violation.Name): $($_.Exception.Message)" -Level Warning |
| 551 | } |
| 552 | |
| 553 | return "Manually research and pin to immutable reference" |
| 554 | } |
| 555 | |
| 556 | function Get-ComplianceReportData { |
| 557 | <# |
| 558 | .SYNOPSIS |
| 559 | Generates a comprehensive compliance report. |
| 560 | #> |
| 561 | [CmdletBinding()] |
| 562 | param( |
| 563 | [DependencyViolation[]]$Violations, |
| 564 | [hashtable[]]$ScannedFiles, |
| 565 | [string]$ScanPath, |
| 566 | [switch]$Remediate |
| 567 | ) |
| 568 | |
| 569 | $report = [ComplianceReport]::new() |
| 570 | $report.ScanPath = $ScanPath |
| 571 | $report.ScannedFiles = $ScannedFiles.Count |
| 572 | $report.Violations = $Violations |
| 573 | |
| 574 | # Calculate metrics |
| 575 | $totalDeps = @($Violations).Count |
| 576 | $unpinnedDeps = @($Violations | Where-Object { $_.Severity -ne 'Info' }).Count |
| 577 | $pinnedDeps = $totalDeps - $unpinnedDeps |
| 578 | |
| 579 | $report.TotalDependencies = $totalDeps |
| 580 | $report.PinnedDependencies = $pinnedDeps |
| 581 | $report.UnpinnedDependencies = $unpinnedDeps |
| 582 | |
| 583 | if ($totalDeps -gt 0) { |
| 584 | $report.ComplianceScore = [math]::Round(($pinnedDeps / $totalDeps) * 100, 2) |
| 585 | } |
| 586 | else { |
| 587 | $report.ComplianceScore = 100.0 |
| 588 | } |
| 589 | |
| 590 | # Generate summary by type |
| 591 | $report.Summary = @{} |
| 592 | foreach ($type in @($Violations | Group-Object Type)) { |
| 593 | $report.Summary[$type.Name] = @{ |
| 594 | Total = $type.Count |
| 595 | High = @($type.Group | Where-Object { $_.Severity -eq 'High' }).Count |
| 596 | Medium = @($type.Group | Where-Object { $_.Severity -eq 'Medium' }).Count |
| 597 | Low = @($type.Group | Where-Object { $_.Severity -eq 'Low' }).Count |
| 598 | } |
| 599 | } |
| 600 | |
| 601 | # Add metadata |
| 602 | $report.Metadata = @{ |
| 603 | PowerShellVersion = $PSVersionTable.PSVersion.ToString() |
| 604 | Platform = $PSVersionTable.Platform |
| 605 | ScanTimestamp = $report.Timestamp.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') |
| 606 | IncludedTypes = $IncludeTypes |
| 607 | ExcludedPaths = $ExcludePaths |
| 608 | RemediationEnabled = $Remediate.IsPresent |
| 609 | ComplianceThreshold = $Threshold |
| 610 | } |
| 611 | |
| 612 | return $report |
| 613 | } |
| 614 | |
| 615 | function Export-ComplianceReport { |
| 616 | <# |
| 617 | .SYNOPSIS |
| 618 | Exports compliance report in specified format. |
| 619 | #> |
| 620 | [CmdletBinding()] |
| 621 | param( |
| 622 | # Use duck typing to avoid class type collision during code coverage instrumentation |
| 623 | $Report, |
| 624 | [string]$Format, |
| 625 | [string]$OutputPath |
| 626 | ) |
| 627 | |
| 628 | # Validate required properties on duck-typed $Report parameter (ComplianceReport schema) |
| 629 | $requiredProperties = @('ComplianceScore', 'Violations', 'TotalDependencies', 'UnpinnedDependencies', 'Metadata') |
| 630 | foreach ($prop in $requiredProperties) { |
| 631 | if ($null -eq $Report.PSObject.Properties[$prop]) { |
| 632 | throw "Report object missing required property: $prop" |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | # Ensure parent directory exists |
| 637 | $parentDir = Split-Path -Path $OutputPath -Parent |
| 638 | if ($parentDir -and -not (Test-Path $parentDir)) { |
| 639 | New-Item -ItemType Directory -Path $parentDir -Force | Out-Null |
| 640 | } |
| 641 | |
| 642 | switch ($Format.ToLower()) { |
| 643 | 'json' { |
| 644 | $Report | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 645 | } |
| 646 | |
| 647 | 'sarif' { |
| 648 | $sarif = @{ |
| 649 | version = "2.1.0" |
| 650 | "`$schema" = "https://json.schemastore.org/sarif-2.1.0.json" |
| 651 | runs = @(@{ |
| 652 | tool = @{ |
| 653 | driver = @{ |
| 654 | name = "dependency-pinning-analyzer" |
| 655 | version = "1.0.0" |
| 656 | informationUri = "https://github.com/microsoft/hve-core" |
| 657 | } |
| 658 | } |
| 659 | results = @($Report.Violations | ForEach-Object { |
| 660 | @{ |
| 661 | ruleId = "dependency-not-pinned" |
| 662 | level = switch ($_.Severity) { 'High' { 'error' } 'Medium' { 'warning' } default { 'note' } } |
| 663 | message = @{ text = $_.Description } |
| 664 | locations = @(@{ |
| 665 | physicalLocation = @{ |
| 666 | artifactLocation = @{ uri = $_.File } |
| 667 | region = @{ startLine = $_.Line } |
| 668 | } |
| 669 | }) |
| 670 | properties = @{ |
| 671 | dependencyName = $_.Name |
| 672 | currentVersion = $_.Version |
| 673 | remediation = $_.Remediation |
| 674 | } |
| 675 | } |
| 676 | }) |
| 677 | }) |
| 678 | } |
| 679 | $sarif | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 680 | } |
| 681 | |
| 682 | 'csv' { |
| 683 | $Report.Violations | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 |
| 684 | } |
| 685 | |
| 686 | 'markdown' { |
| 687 | $markdown = @" |
| 688 | # Dependency Pinning Compliance Report |
| 689 | |
| 690 | **Scan Date:** $($Report.Timestamp.ToString('yyyy-MM-dd HH:mm:ss')) |
| 691 | **Scan Path:** $($Report.ScanPath) |
| 692 | **Compliance Score:** $($Report.ComplianceScore)% |
| 693 | |
| 694 | ## Summary |
| 695 | |
| 696 | | Metric | Count | |
| 697 | |--------|--------| |
| 698 | | Total Files Scanned | $($Report.ScannedFiles) | |
| 699 | | Total Dependencies | $($Report.TotalDependencies) | |
| 700 | | Pinned Dependencies | $($Report.PinnedDependencies) | |
| 701 | | Unpinned Dependencies | $($Report.UnpinnedDependencies) | |
| 702 | |
| 703 | ## Violations by Type |
| 704 | |
| 705 | "@ |
| 706 | foreach ($type in $Report.Summary.Keys) { |
| 707 | $summary = $Report.Summary[$type] |
| 708 | $markdown += @" |
| 709 | |
| 710 | ### $type |
| 711 | - **Total:** $($summary.Total) |
| 712 | - **High Severity:** $($summary.High) |
| 713 | - **Medium Severity:** $($summary.Medium) |
| 714 | - **Low Severity:** $($summary.Low) |
| 715 | |
| 716 | "@ |
| 717 | } |
| 718 | |
| 719 | if ($Report.Violations.Count -gt 0) { |
| 720 | $markdown += @" |
| 721 | |
| 722 | ## Detailed Violations |
| 723 | |
| 724 | | File | Line | Type | Dependency | Current Version | Severity | Remediation | |
| 725 | |------|------|------|------------|----------------|----------|-------------| |
| 726 | "@ |
| 727 | foreach ($violation in $Report.Violations) { |
| 728 | $markdown += "|$($violation.File)|$($violation.Line)|$($violation.Type)|$($violation.Name)|$($violation.Version)|$($violation.Severity)|$($violation.Remediation)|`n" |
| 729 | } |
| 730 | } |
| 731 | |
| 732 | $markdown | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 733 | } |
| 734 | |
| 735 | 'table' { |
| 736 | # Display formatted table to console and save simple text format |
| 737 | if ($Report.Violations.Count -gt 0) { |
| 738 | $Report.Violations | Format-Table -Property File, Line, Type, Name, Version, Severity -AutoSize | Out-File -FilePath $OutputPath -Encoding UTF8 -Width 200 |
| 739 | } |
| 740 | else { |
| 741 | "No dependency pinning violations found." | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 742 | } |
| 743 | } |
| 744 | } |
| 745 | |
| 746 | Write-PinningLog "Compliance report exported to: $OutputPath" -Level Success |
| 747 | } |
| 748 | |
| 749 | function Export-CICDArtifact { |
| 750 | <# |
| 751 | .SYNOPSIS |
| 752 | Exports compliance report as CI/CD artifacts for both GitHub Actions and Azure DevOps. |
| 753 | #> |
| 754 | [CmdletBinding()] |
| 755 | param( |
| 756 | [ComplianceReport]$Report, |
| 757 | [string]$ReportPath |
| 758 | ) |
| 759 | |
| 760 | Write-PinningLog "Preparing compliance artifacts for CI/CD systems..." -Level Info |
| 761 | |
| 762 | $platform = Get-CIPlatform |
| 763 | Write-PinningLog "Detected $platform environment - setting up artifacts" -Level Info |
| 764 | |
| 765 | # Set CI outputs (works for both GitHub Actions and Azure DevOps) |
| 766 | Set-CIOutput -Name 'dependency-report' -Value $ReportPath -IsOutput |
| 767 | Set-CIOutput -Name 'compliance-score' -Value $Report.ComplianceScore -IsOutput |
| 768 | Set-CIOutput -Name 'unpinned-count' -Value $Report.UnpinnedDependencies -IsOutput |
| 769 | |
| 770 | # Create summary content |
| 771 | $summaryContent = @" |
| 772 | # 📌 Dependency Pinning Analysis |
| 773 | |
| 774 | **Compliance Score:** $($Report.ComplianceScore)% |
| 775 | **Unpinned Dependencies:** $($Report.UnpinnedDependencies) |
| 776 | **Total Dependencies Scanned:** $($Report.TotalDependencies) |
| 777 | |
| 778 | $(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!" }) |
| 779 | "@ |
| 780 | |
| 781 | # Write step summary |
| 782 | Write-CIStepSummary -Content $summaryContent |
| 783 | |
| 784 | # Publish artifact |
| 785 | Publish-CIArtifact -Path $ReportPath -Name 'dependency-pinning-report' -ContainerFolder 'dependency-pinning' |
| 786 | |
| 787 | # Set up local artifact directory for GitHub Actions upload-artifact action |
| 788 | if ($platform -eq 'github') { |
| 789 | $artifactDir = Join-Path $PWD "dependency-pinning-artifacts" |
| 790 | New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null |
| 791 | Copy-Item -Path $ReportPath -Destination $artifactDir -Force |
| 792 | } |
| 793 | |
| 794 | Write-PinningLog "Compliance artifacts prepared for CI/CD consumption" -Level Success |
| 795 | } |
| 796 | |
| 797 | function Invoke-DependencyPinningAnalysis { |
| 798 | <# |
| 799 | .SYNOPSIS |
| 800 | Orchestrates dependency pinning compliance analysis. |
| 801 | #> |
| 802 | [CmdletBinding()] |
| 803 | [OutputType([void])] |
| 804 | param( |
| 805 | [Parameter()] |
| 806 | [string]$Path = ".", |
| 807 | |
| 808 | [Parameter()] |
| 809 | [switch]$Recursive, |
| 810 | |
| 811 | [Parameter()] |
| 812 | [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads", |
| 813 | |
| 814 | [Parameter()] |
| 815 | [string]$ExcludePaths = "", |
| 816 | |
| 817 | [Parameter()] |
| 818 | [string]$Format = 'json', |
| 819 | |
| 820 | [Parameter()] |
| 821 | [string]$OutputPath = 'logs/dependency-pinning-results.json', |
| 822 | |
| 823 | [Parameter()] |
| 824 | [switch]$FailOnUnpinned, |
| 825 | |
| 826 | [Parameter()] |
| 827 | [int]$Threshold = 95, |
| 828 | |
| 829 | [Parameter()] |
| 830 | [switch]$Remediate |
| 831 | ) |
| 832 | |
| 833 | Write-PinningLog "Starting dependency pinning compliance analysis..." -Level Info |
| 834 | Write-PinningLog "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info |
| 835 | Write-PinningLog "Platform: $($PSVersionTable.Platform)" -Level Info |
| 836 | |
| 837 | # Parse include types and exclude paths |
| 838 | $typesToCheck = $IncludeTypes.Split(',') | ForEach-Object { $_.Trim() } |
| 839 | $excludePatterns = if ($ExcludePaths) { $ExcludePaths.Split(',') | ForEach-Object { $_.Trim() } } else { @() } |
| 840 | |
| 841 | Write-PinningLog "Scanning path: $Path" -Level Info |
| 842 | Write-PinningLog "Include types: $($typesToCheck -join ', ')" -Level Info |
| 843 | if ($excludePatterns) { Write-PinningLog "Exclude patterns: $($excludePatterns -join ', ')" -Level Info } |
| 844 | |
| 845 | # Discover files to scan |
| 846 | $filesToScan = @(Get-FilesToScan -ScanPath $Path -Types $typesToCheck -ExcludePatterns $excludePatterns -Recursive:$Recursive) |
| 847 | Write-PinningLog "Found $(@($filesToScan).Count) files to scan" -Level Info |
| 848 | |
| 849 | # Scan for violations |
| 850 | $allViolations = @() |
| 851 | foreach ($fileInfo in $filesToScan) { |
| 852 | Write-PinningLog "Scanning: $($fileInfo.RelativePath)" -Level Info |
| 853 | $violations = @(Get-DependencyViolation -FileInfo $fileInfo) |
| 854 | |
| 855 | # Add remediation suggestions |
| 856 | foreach ($violation in $violations) { |
| 857 | $violation.Remediation = Get-RemediationSuggestion -Violation $violation -Remediate:$Remediate |
| 858 | } |
| 859 | |
| 860 | $allViolations += $violations |
| 861 | } |
| 862 | |
| 863 | Write-PinningLog "Found $(@($allViolations).Count) dependency pinning violations" -Level Info |
| 864 | |
| 865 | # Generate compliance report |
| 866 | $report = Get-ComplianceReportData -Violations $allViolations -ScannedFiles $filesToScan -ScanPath $Path -Remediate:$Remediate |
| 867 | |
| 868 | # Export report |
| 869 | Export-ComplianceReport -Report $report -Format $Format -OutputPath $OutputPath |
| 870 | |
| 871 | # Export CI/CD artifacts |
| 872 | Export-CICDArtifact -Report $report -ReportPath $OutputPath |
| 873 | |
| 874 | # Display summary |
| 875 | Write-PinningLog "Compliance Analysis Complete!" -Level Success |
| 876 | Write-PinningLog "Compliance Score: $($report.ComplianceScore)%" -Level Info |
| 877 | Write-PinningLog "Total Dependencies: $($report.TotalDependencies)" -Level Info |
| 878 | Write-PinningLog "Unpinned Dependencies: $($report.UnpinnedDependencies)" -Level Info |
| 879 | |
| 880 | if ($report.UnpinnedDependencies -gt 0) { |
| 881 | Write-PinningLog "$($report.UnpinnedDependencies) dependencies require SHA pinning for security compliance" -Level Warning |
| 882 | |
| 883 | # Check threshold compliance |
| 884 | if ($report.ComplianceScore -lt $Threshold) { |
| 885 | Write-PinningLog "Compliance score $($report.ComplianceScore)% is below threshold $Threshold%" -Level Error |
| 886 | |
| 887 | if ($FailOnUnpinned) { |
| 888 | Write-PinningLog "Failing build due to compliance threshold violation (-FailOnUnpinned enabled)" -Level Error |
| 889 | throw "Compliance score $($report.ComplianceScore)% is below threshold $Threshold% (-FailOnUnpinned enabled)" |
| 890 | } |
| 891 | else { |
| 892 | Write-PinningLog "Threshold violation detected but continuing (soft-fail mode)" -Level Warning |
| 893 | } |
| 894 | } |
| 895 | else { |
| 896 | Write-PinningLog "Compliance score $($report.ComplianceScore)% meets threshold $Threshold%" -Level Info |
| 897 | } |
| 898 | } |
| 899 | else { |
| 900 | Write-PinningLog "All dependencies are properly pinned! ✅ (100% compliance, exceeds $Threshold% threshold)" -Level Success |
| 901 | } |
| 902 | } |
| 903 | |
| 904 | #endregion Functions |
| 905 | |
| 906 | #region Main Execution |
| 907 | if ($MyInvocation.InvocationName -ne '.') { |
| 908 | try { |
| 909 | Invoke-DependencyPinningAnalysis ` |
| 910 | -Path $Path ` |
| 911 | -Recursive:$Recursive ` |
| 912 | -IncludeTypes $IncludeTypes ` |
| 913 | -ExcludePaths $ExcludePaths ` |
| 914 | -Format $Format ` |
| 915 | -OutputPath $OutputPath ` |
| 916 | -FailOnUnpinned:$FailOnUnpinned ` |
| 917 | -Threshold $Threshold ` |
| 918 | -Remediate:$Remediate |
| 919 | exit 0 |
| 920 | } |
| 921 | catch { |
| 922 | Write-Error -ErrorAction Continue "Test-DependencyPinning failed: $($_.Exception.Message)" |
| 923 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 924 | exit 1 |
| 925 | } |
| 926 | } |
| 927 | #endregion Main Execution |
| 928 | |