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