microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Test-DependencyPinning.ps1
1168lines · 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 dependency 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 dependency 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 | [ValidateSet('json', 'sarif', 'csv', 'markdown', 'table')] |
| 97 | [string]$Format = 'json', |
| 98 | |
| 99 | [Parameter(Mandatory = $false)] |
| 100 | [string]$OutputPath = 'logs/dependency-pinning-results.json', |
| 101 | |
| 102 | [Parameter(Mandatory = $false)] |
| 103 | [switch]$FailOnUnpinned, |
| 104 | |
| 105 | [Parameter(Mandatory = $false)] |
| 106 | [string]$ExcludePaths = "", |
| 107 | |
| 108 | [Parameter(Mandatory = $false)] |
| 109 | [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads,workflow-npm-commands", |
| 110 | |
| 111 | [Parameter(Mandatory = $false)] |
| 112 | [ValidateRange(0, 100)] |
| 113 | [int]$Threshold = 95, |
| 114 | |
| 115 | [Parameter(Mandatory = $false)] |
| 116 | [switch]$Remediate |
| 117 | ) |
| 118 | |
| 119 | $ErrorActionPreference = 'Stop' |
| 120 | |
| 121 | # Import CIHelpers for workflow command escaping |
| 122 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 123 | Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force |
| 124 | |
| 125 | $script:GitHubApiBase = Get-GitHubApiBase |
| 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 = "$script:GitHubApiBase/repos/{0}/commits/{1}" |
| 140 | } |
| 141 | |
| 142 | 'npm' = @{ |
| 143 | FilePatterns = @('**/package.json') |
| 144 | ExcludePatterns = @('node_modules') |
| 145 | ValidationFunc = 'Get-NpmDependencyViolations' |
| 146 | RemediationUrl = 'https://registry.npmjs.org/{0}/{1}' |
| 147 | } |
| 148 | |
| 149 | 'pip' = @{ |
| 150 | FilePatterns = @('**/requirements*.txt', '**/Pipfile', '**/pyproject.toml', '**/setup.py') |
| 151 | ExcludePatterns = @('.venv', 'venv', '.tox', '.nox', '__pypackages__') |
| 152 | VersionPatterns = @( |
| 153 | @{ |
| 154 | Pattern = '([a-zA-Z0-9\-_]+)==([^#\s]+)' |
| 155 | Groups = @{ Package = 1; Version = 2 } |
| 156 | Description = 'Python pip requirements' |
| 157 | } |
| 158 | ) |
| 159 | SHAPattern = '^[a-fA-F0-9]{40}$' |
| 160 | RemediationUrl = 'https://pypi.org/pypi/{0}/{1}/json' |
| 161 | } |
| 162 | |
| 163 | 'shell-downloads' = @{ |
| 164 | FilePatterns = @('**/.devcontainer/scripts/*.sh', '**/scripts/*.sh') |
| 165 | ExcludePatterns = @('Fixtures') |
| 166 | ValidationFunc = 'Test-ShellDownloadSecurity' |
| 167 | Description = 'Shell script downloads must include checksum verification' |
| 168 | } |
| 169 | |
| 170 | 'workflow-npm-commands' = @{ |
| 171 | FilePatterns = @('**/.github/workflows/*.yml', '**/.github/workflows/*.yaml') |
| 172 | ValidationFunc = 'Get-WorkflowNpmCommandViolations' |
| 173 | Description = 'Workflow npm install/update commands should use npm ci' |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | # DependencyViolation and ComplianceReport classes moved to ./Modules/SecurityClasses.psm1 |
| 178 | |
| 179 | #region Functions |
| 180 | |
| 181 | function Test-NpmCommandLine { |
| 182 | <# |
| 183 | .SYNOPSIS |
| 184 | Tests whether a line contains an unpinned npm command. |
| 185 | .DESCRIPTION |
| 186 | Matches npm install, npm i, npm update, and npm install-test commands. |
| 187 | Does not match npm ci, npm run, npm test, npm audit, or npx. |
| 188 | .PARAMETER Line |
| 189 | The text line to test for npm commands. |
| 190 | .OUTPUTS |
| 191 | System.String or $null |
| 192 | #> |
| 193 | param( |
| 194 | [Parameter(Mandatory)] |
| 195 | [string]$Line |
| 196 | ) |
| 197 | |
| 198 | if ($Line -match '\bnpm\s+(install-test|install|update)\b') { |
| 199 | return $Matches[0] |
| 200 | } |
| 201 | if ($Line -match '\bnpm\s+i\b(?!nstall|nit)') { |
| 202 | return $Matches[0] |
| 203 | } |
| 204 | |
| 205 | return $null |
| 206 | } |
| 207 | |
| 208 | function New-NpmCommandViolation { |
| 209 | <# |
| 210 | .SYNOPSIS |
| 211 | Creates a DependencyViolation for an unpinned npm command. |
| 212 | .DESCRIPTION |
| 213 | Constructs a DependencyViolation object with standard fields for |
| 214 | npm command violations detected in workflow run: steps. |
| 215 | .PARAMETER FileInfo |
| 216 | Hashtable with Path, Type, and RelativePath keys. |
| 217 | .PARAMETER LineNumber |
| 218 | 1-based line number of the violation. |
| 219 | .PARAMETER Line |
| 220 | The source line containing the npm command. |
| 221 | .PARAMETER Command |
| 222 | The matched npm command string. |
| 223 | .OUTPUTS |
| 224 | DependencyViolation |
| 225 | #> |
| 226 | param( |
| 227 | [Parameter(Mandatory)] |
| 228 | [hashtable]$FileInfo, |
| 229 | [Parameter(Mandatory)] |
| 230 | [int]$LineNumber, |
| 231 | [Parameter(Mandatory)] |
| 232 | [string]$Line, |
| 233 | [Parameter(Mandatory)] |
| 234 | [string]$Command |
| 235 | ) |
| 236 | |
| 237 | $violation = [DependencyViolation]::new( |
| 238 | $FileInfo.RelativePath, |
| 239 | $LineNumber, |
| 240 | 'workflow-npm-commands', |
| 241 | $Command, |
| 242 | 'Medium', |
| 243 | "Unpinned npm command detected: '$Command'. Use 'npm ci' for deterministic installs from lockfile." |
| 244 | ) |
| 245 | $violation.ViolationType = 'Unpinned' |
| 246 | $violation.CurrentRef = $Line.Trim() |
| 247 | $violation.Remediation = "Replace '$Command' with 'npm ci' for reproducible builds." |
| 248 | return $violation |
| 249 | } |
| 250 | |
| 251 | function Get-WorkflowNpmCommandViolations { |
| 252 | <# |
| 253 | .SYNOPSIS |
| 254 | Detects unpinned npm install commands in GitHub Actions workflow run: steps. |
| 255 | .DESCRIPTION |
| 256 | Scans workflow YAML files for run: blocks and detects npm commands that |
| 257 | modify the dependency tree (install, i, update, install-test). Commands |
| 258 | that use the lockfile deterministically (ci) or do not install packages |
| 259 | (run, test, audit) are not flagged. |
| 260 | |
| 261 | Uses indentation-aware parsing to confine detection to actual run: block |
| 262 | content, reducing false positives from YAML comments or unrelated keys. |
| 263 | .PARAMETER FileInfo |
| 264 | Hashtable with Path, Type, and RelativePath keys identifying the file to scan. |
| 265 | .OUTPUTS |
| 266 | DependencyViolation[] |
| 267 | #> |
| 268 | param( |
| 269 | [Parameter(Mandatory)] |
| 270 | [hashtable]$FileInfo |
| 271 | ) |
| 272 | |
| 273 | $violations = @() |
| 274 | $totalNpmCommands = 0 |
| 275 | $filePath = $FileInfo.Path |
| 276 | |
| 277 | if (-not (Test-Path -LiteralPath $filePath)) { |
| 278 | return @{ TotalCount = 0; Violations = @() } |
| 279 | } |
| 280 | |
| 281 | $lines = Get-Content -LiteralPath $filePath |
| 282 | $inRunBlock = $false |
| 283 | $runBlockIndent = 0 |
| 284 | |
| 285 | for ($i = 0; $i -lt $lines.Count; $i++) { |
| 286 | $line = $lines[$i] |
| 287 | $trimmed = $line.TrimStart() |
| 288 | |
| 289 | if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { |
| 290 | continue |
| 291 | } |
| 292 | |
| 293 | $currentIndent = $line.Length - $line.TrimStart().Length |
| 294 | |
| 295 | if ($trimmed -match '^run:\s*(.*)$') { |
| 296 | $runContent = $Matches[1].Trim() |
| 297 | $runBlockIndent = $currentIndent |
| 298 | |
| 299 | if ($runContent -and $runContent -notmatch '^[|>]') { |
| 300 | $npmMatch = Test-NpmCommandLine -Line $runContent |
| 301 | if ($npmMatch) { |
| 302 | $totalNpmCommands++ |
| 303 | $violations += New-NpmCommandViolation -FileInfo $FileInfo -LineNumber ($i + 1) -Line $runContent -Command $npmMatch |
| 304 | } |
| 305 | $inRunBlock = $false |
| 306 | } else { |
| 307 | $inRunBlock = $true |
| 308 | } |
| 309 | continue |
| 310 | } |
| 311 | |
| 312 | if ($inRunBlock) { |
| 313 | if ($currentIndent -le $runBlockIndent) { |
| 314 | $inRunBlock = $false |
| 315 | if ($trimmed -match '^run:\s*(.*)$') { |
| 316 | $i-- |
| 317 | continue |
| 318 | } |
| 319 | } else { |
| 320 | if ($trimmed.StartsWith('#')) { |
| 321 | continue |
| 322 | } |
| 323 | $npmMatch = Test-NpmCommandLine -Line $trimmed |
| 324 | if ($npmMatch) { |
| 325 | $totalNpmCommands++ |
| 326 | $violations += New-NpmCommandViolation -FileInfo $FileInfo -LineNumber ($i + 1) -Line $trimmed -Command $npmMatch |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | return @{ TotalCount = $totalNpmCommands; Violations = $violations } |
| 333 | } |
| 334 | |
| 335 | function Test-ShellDownloadSecurity { |
| 336 | <# |
| 337 | .SYNOPSIS |
| 338 | Scans shell scripts for curl/wget downloads lacking checksum verification. |
| 339 | |
| 340 | .DESCRIPTION |
| 341 | Analyzes shell scripts to detect download commands (curl/wget) that do not |
| 342 | have corresponding checksum verification (sha256sum/shasum) within the |
| 343 | following lines. |
| 344 | |
| 345 | .PARAMETER FileInfo |
| 346 | Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan. |
| 347 | #> |
| 348 | [CmdletBinding()] |
| 349 | param( |
| 350 | [Parameter(Mandatory)] |
| 351 | [hashtable]$FileInfo |
| 352 | ) |
| 353 | |
| 354 | $FilePath = $FileInfo.Path |
| 355 | |
| 356 | if (-not (Test-Path $FilePath)) { |
| 357 | return @{ TotalCount = 0; Violations = @() } |
| 358 | } |
| 359 | |
| 360 | $lines = Get-Content $FilePath |
| 361 | $violations = @() |
| 362 | $totalDownloads = 0 |
| 363 | |
| 364 | # Pattern to match curl/wget download commands |
| 365 | $downloadPattern = '(curl|wget)\s+.*https?://[^\s]+' |
| 366 | $checksumPattern = 'sha256sum|shasum|Get-FileHash|openssl\s+dgst\s+-sha256|sha256sum\s+-c' |
| 367 | |
| 368 | for ($i = 0; $i -lt $lines.Count; $i++) { |
| 369 | $line = $lines[$i] |
| 370 | if ($line -match $downloadPattern) { |
| 371 | $totalDownloads++ |
| 372 | # Check next 5 lines for checksum verification |
| 373 | $hasChecksum = $false |
| 374 | $searchEnd = [Math]::Min($i + 5, $lines.Count - 1) |
| 375 | |
| 376 | for ($j = $i; $j -le $searchEnd; $j++) { |
| 377 | if ($lines[$j] -match $checksumPattern) { |
| 378 | $hasChecksum = $true |
| 379 | break |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | if (-not $hasChecksum) { |
| 384 | $violation = [DependencyViolation]::new() |
| 385 | $violation.File = $FileInfo.RelativePath |
| 386 | $violation.Line = $i + 1 |
| 387 | $violation.Type = $FileInfo.Type |
| 388 | $violation.Name = $line.Trim() |
| 389 | $violation.Severity = 'Medium' |
| 390 | $violation.ViolationType = 'Unpinned' |
| 391 | $violation.Description = 'Download without checksum verification' |
| 392 | $violation.Metadata = @{ Pattern = $line.Trim() } |
| 393 | $violations += $violation |
| 394 | } |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | return @{ TotalCount = $totalDownloads; Violations = $violations } |
| 399 | } |
| 400 | |
| 401 | function Get-NpmDependencyViolations { |
| 402 | <# |
| 403 | .SYNOPSIS |
| 404 | Analyzes package.json files for unpinned npm dependencies. |
| 405 | .DESCRIPTION |
| 406 | Parses package.json as JSON and checks dependency sections |
| 407 | (dependencies, devDependencies, peerDependencies, optionalDependencies) |
| 408 | for exact version pinning. Versions must be exact semver (e.g. 1.2.3) |
| 409 | without range operators like ^, ~, *, >=, ||, or URL/git references. |
| 410 | .PARAMETER FileInfo |
| 411 | Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan. |
| 412 | .OUTPUTS |
| 413 | Array of PSCustomObjects representing dependency violations. |
| 414 | #> |
| 415 | [CmdletBinding()] |
| 416 | param( |
| 417 | [Parameter(Mandatory)] |
| 418 | [hashtable]$FileInfo |
| 419 | ) |
| 420 | |
| 421 | $filePath = $FileInfo.Path |
| 422 | $relativePath = $FileInfo.RelativePath |
| 423 | $type = $FileInfo.Type |
| 424 | $violations = @() |
| 425 | $totalCount = 0 |
| 426 | |
| 427 | if (-not (Test-Path -Path $filePath -PathType Leaf)) { |
| 428 | return @{ TotalCount = 0; Violations = @() } |
| 429 | } |
| 430 | |
| 431 | try { |
| 432 | $content = Get-Content -Path $filePath -Raw -ErrorAction Stop |
| 433 | $packageJson = $content | ConvertFrom-Json -ErrorAction Stop |
| 434 | } |
| 435 | catch { |
| 436 | Write-Warning "Failed to parse $relativePath as JSON: $_" |
| 437 | return @{ TotalCount = 0; Violations = @() } |
| 438 | } |
| 439 | |
| 440 | # Build a line-number lookup from raw file content |
| 441 | $lines = Get-Content -Path $filePath -ErrorAction SilentlyContinue |
| 442 | |
| 443 | $dependencySections = @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies') |
| 444 | |
| 445 | foreach ($section in $dependencySections) { |
| 446 | $deps = $packageJson.$section |
| 447 | if ($null -eq $deps) { |
| 448 | continue |
| 449 | } |
| 450 | |
| 451 | foreach ($prop in $deps.PSObject.Properties) { |
| 452 | $packageName = $prop.Name |
| 453 | $version = $prop.Value |
| 454 | |
| 455 | if ([string]::IsNullOrWhiteSpace($version)) { |
| 456 | continue |
| 457 | } |
| 458 | |
| 459 | $totalCount++ |
| 460 | $isPinned = Test-NpmExactVersion -Version $version |
| 461 | |
| 462 | if (-not $isPinned) { |
| 463 | # Find the line number by searching for the package name in the file |
| 464 | $lineNumber = 1 |
| 465 | if ($null -ne $lines) { |
| 466 | $escapedName = [regex]::Escape($packageName) |
| 467 | for ($i = 0; $i -lt $lines.Count; $i++) { |
| 468 | if ($lines[$i] -match """$escapedName""\s*:") { |
| 469 | $lineNumber = $i + 1 |
| 470 | break |
| 471 | } |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | $violation = [DependencyViolation]::new() |
| 476 | $violation.File = $relativePath |
| 477 | $violation.Line = $lineNumber |
| 478 | $violation.Type = $type |
| 479 | $violation.Name = $packageName |
| 480 | $violation.Version = $version |
| 481 | $violation.Severity = 'Medium' |
| 482 | $violation.ViolationType = 'Unpinned' |
| 483 | $violation.Description = "Unpinned npm dependency in $section" |
| 484 | $violation.Metadata = @{ Section = $section } |
| 485 | $violations += $violation |
| 486 | } |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | return @{ TotalCount = $totalCount; Violations = $violations } |
| 491 | } |
| 492 | |
| 493 | function Test-NpmExactVersion { |
| 494 | <# |
| 495 | .SYNOPSIS |
| 496 | Tests whether an npm version string is an exact pinned version. |
| 497 | .DESCRIPTION |
| 498 | Returns $true for exact semver versions (e.g. 1.2.3, 1.0.0-beta.1). |
| 499 | Returns $false for ranges, wildcards, URLs, tags, and git references. |
| 500 | #> |
| 501 | [CmdletBinding()] |
| 502 | param( |
| 503 | [Parameter(Mandatory)] |
| 504 | [string]$Version |
| 505 | ) |
| 506 | |
| 507 | # Reject range operators, wildcards, URLs, git refs, and tags like "latest" |
| 508 | if ($Version -match '^[~^>=<*|]' -or |
| 509 | $Version -match '://' -or |
| 510 | $Version -match '\.git\b' -or |
| 511 | $Version -match '\s*\|\|' -or |
| 512 | $Version -match '^\w+$' -and $Version -notmatch '^\d') { |
| 513 | return $false |
| 514 | } |
| 515 | |
| 516 | # Accept exact semver: major.minor.patch with optional prerelease/build metadata |
| 517 | return $Version -match '^\d+\.\d+\.\d+(-[a-zA-Z0-9._-]+)?(\+[a-zA-Z0-9._-]+)?$' |
| 518 | } |
| 519 | |
| 520 | function Get-FilesToScan { |
| 521 | <# |
| 522 | .SYNOPSIS |
| 523 | Discovers files to scan based on dependency type patterns. |
| 524 | #> |
| 525 | [CmdletBinding()] |
| 526 | param( |
| 527 | [string]$ScanPath, |
| 528 | [string[]]$Types, |
| 529 | [string[]]$ExcludePatterns |
| 530 | ) |
| 531 | |
| 532 | $allFiles = @() |
| 533 | |
| 534 | foreach ($type in $Types) { |
| 535 | if ($DependencyPatterns.ContainsKey($type)) { |
| 536 | $patterns = $DependencyPatterns[$type].FilePatterns |
| 537 | |
| 538 | foreach ($pattern in $patterns) { |
| 539 | try { |
| 540 | # Decompose glob into a directory prefix and a leaf filename filter. |
| 541 | # Get-ChildItem -Path does not expand ** globs on all platforms, |
| 542 | # so we strip the ** segments and use -Recurse with -Filter instead. |
| 543 | $segments = $pattern -split '[/\\]' |
| 544 | $leafFilter = $segments[-1] |
| 545 | $dirSegments = $segments[0..($segments.Length - 2)] | Where-Object { $_ -ne '**' } |
| 546 | |
| 547 | if ($dirSegments.Count -gt 0) { |
| 548 | $basePath = Join-Path $ScanPath ($dirSegments -join [System.IO.Path]::DirectorySeparatorChar) |
| 549 | } |
| 550 | else { |
| 551 | $basePath = $ScanPath |
| 552 | } |
| 553 | |
| 554 | if (-not (Test-Path -Path $basePath -PathType Container)) { |
| 555 | continue |
| 556 | } |
| 557 | |
| 558 | $files = Get-ChildItem -Path $basePath -Filter $leafFilter -Recurse -File -ErrorAction SilentlyContinue |
| 559 | |
| 560 | # Merge type-specific exclude patterns with caller-provided patterns |
| 561 | $mergedExcludes = @() |
| 562 | if ($ExcludePatterns) { |
| 563 | $mergedExcludes += @($ExcludePatterns) |
| 564 | } |
| 565 | if ($DependencyPatterns[$type].ContainsKey('ExcludePatterns')) { |
| 566 | $mergedExcludes += $DependencyPatterns[$type].ExcludePatterns |
| 567 | } |
| 568 | |
| 569 | if ($mergedExcludes) { |
| 570 | foreach ($exclude in $mergedExcludes) { |
| 571 | $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" } |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | $allFiles += $files | ForEach-Object { |
| 576 | @{ |
| 577 | Path = $_.FullName |
| 578 | Type = $type |
| 579 | RelativePath = [System.IO.Path]::GetRelativePath($ScanPath, $_.FullName) |
| 580 | } |
| 581 | } |
| 582 | } |
| 583 | catch { |
| 584 | Write-SecurityLog -CIAnnotation "Error scanning for $type files with pattern $pattern`: $($_.Exception.Message)" -Level Warning |
| 585 | } |
| 586 | } |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | return $allFiles | Sort-Object Path -Unique |
| 591 | } |
| 592 | |
| 593 | function Test-SHAPinning { |
| 594 | <# |
| 595 | .SYNOPSIS |
| 596 | Tests if a version reference is properly SHA-pinned. |
| 597 | #> |
| 598 | [CmdletBinding()] |
| 599 | param( |
| 600 | [string]$Version, |
| 601 | [string]$Type |
| 602 | ) |
| 603 | |
| 604 | if ($DependencyPatterns.ContainsKey($Type) -and $DependencyPatterns[$Type].SHAPattern) { |
| 605 | $shaPattern = $DependencyPatterns[$Type].SHAPattern |
| 606 | return $Version -match $shaPattern |
| 607 | } |
| 608 | |
| 609 | return $false |
| 610 | } |
| 611 | |
| 612 | function Get-DependencyViolation { |
| 613 | <# |
| 614 | .SYNOPSIS |
| 615 | Scans a file for dependency pinning violations. |
| 616 | #> |
| 617 | [CmdletBinding()] |
| 618 | param( |
| 619 | [hashtable]$FileInfo |
| 620 | ) |
| 621 | |
| 622 | $violations = @() |
| 623 | $filePath = $FileInfo.Path |
| 624 | $fileType = $FileInfo.Type |
| 625 | |
| 626 | if (!(Test-Path $filePath)) { |
| 627 | return @{ TotalCount = 0; Violations = @() } |
| 628 | } |
| 629 | |
| 630 | # Check if this type uses a validation function instead of regex patterns |
| 631 | if ($null -ne $DependencyPatterns[$fileType].ValidationFunc) { |
| 632 | $funcName = $DependencyPatterns[$fileType].ValidationFunc |
| 633 | $scanResult = & $funcName -FileInfo $FileInfo |
| 634 | |
| 635 | if ($null -eq $scanResult) { |
| 636 | return @{ TotalCount = 0; Violations = @() } |
| 637 | } |
| 638 | |
| 639 | foreach ($v in @($scanResult.Violations)) { |
| 640 | if ($null -eq $v) { |
| 641 | continue |
| 642 | } |
| 643 | |
| 644 | if (-not ($v -is [DependencyViolation])) { |
| 645 | $actualType = $v.GetType().FullName |
| 646 | throw "Validation function '$funcName' must return [DependencyViolation] objects, got '$actualType'." |
| 647 | } |
| 648 | |
| 649 | if (-not $v.File) { |
| 650 | $v.File = $FileInfo.RelativePath |
| 651 | } |
| 652 | |
| 653 | if ($v.Line -lt 1) { |
| 654 | $v.Line = 1 |
| 655 | } |
| 656 | |
| 657 | if (-not $v.Type) { |
| 658 | $v.Type = $fileType |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | return $scanResult |
| 663 | } |
| 664 | |
| 665 | try { |
| 666 | $content = Get-Content -Path $filePath -Raw |
| 667 | $lines = Get-Content -Path $filePath |
| 668 | |
| 669 | $patterns = $DependencyPatterns[$fileType].VersionPatterns |
| 670 | $totalCount = 0 |
| 671 | |
| 672 | foreach ($patternInfo in $patterns) { |
| 673 | $pattern = $patternInfo.Pattern |
| 674 | $description = $patternInfo.Description |
| 675 | |
| 676 | $regexMatches = [regex]::Matches($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) |
| 677 | $totalCount += @($regexMatches).Count |
| 678 | |
| 679 | foreach ($match in $regexMatches) { |
| 680 | # Find line number |
| 681 | $lineNumber = 1 |
| 682 | $position = $match.Index |
| 683 | for ($i = 0; $i -lt $position; $i++) { |
| 684 | if ($content[$i] -eq "`n") { |
| 685 | $lineNumber++ |
| 686 | } |
| 687 | } |
| 688 | |
| 689 | # Extract dependency information |
| 690 | $dependencyName = $match.Groups[1].Value |
| 691 | $version = $match.Groups[2].Value |
| 692 | |
| 693 | # Check if properly pinned |
| 694 | if (!(Test-SHAPinning -Version $version -Type $fileType)) { |
| 695 | $violation = [DependencyViolation]::new() |
| 696 | $violation.File = $FileInfo.RelativePath |
| 697 | $violation.Line = $lineNumber |
| 698 | $violation.Type = $fileType |
| 699 | $violation.Name = $dependencyName |
| 700 | $violation.Version = $version |
| 701 | $violation.CurrentRef = $match.Value |
| 702 | $violation.Description = "Unpinned dependency: $description" |
| 703 | $violation.Severity = if ($fileType -eq 'github-actions') { 'High' } else { 'Medium' } |
| 704 | $violation.ViolationType = 'Unpinned' |
| 705 | $violation.Metadata['PatternDescription'] = $description |
| 706 | $violation.Metadata['LineContent'] = $lines[$lineNumber - 1] |
| 707 | |
| 708 | $violations += $violation |
| 709 | } |
| 710 | } |
| 711 | } |
| 712 | } |
| 713 | catch { |
| 714 | Write-SecurityLog -CIAnnotation "Error scanning file $filePath`: $($_.Exception.Message)" -Level Warning |
| 715 | } |
| 716 | |
| 717 | return @{ TotalCount = $totalCount; Violations = $violations } |
| 718 | } |
| 719 | |
| 720 | function Get-RemediationSuggestion { |
| 721 | <# |
| 722 | .SYNOPSIS |
| 723 | Generates remediation suggestions for unpinned dependencies. |
| 724 | #> |
| 725 | [CmdletBinding()] |
| 726 | param( |
| 727 | [DependencyViolation]$Violation, |
| 728 | |
| 729 | [switch]$Remediate |
| 730 | ) |
| 731 | |
| 732 | $type = $Violation.Type |
| 733 | $name = $Violation.Name |
| 734 | $version = $Violation.Version |
| 735 | |
| 736 | if (!$Remediate) { |
| 737 | return "Enable -Remediate flag for specific SHA suggestions" |
| 738 | } |
| 739 | |
| 740 | try { |
| 741 | switch ($type) { |
| 742 | 'github-actions' { |
| 743 | # For GitHub Actions, resolve tag to commit SHA |
| 744 | $apiUrl = "$script:GitHubApiBase/repos/$name/commits/$version" |
| 745 | $headers = @{} |
| 746 | |
| 747 | if ($env:GITHUB_TOKEN) { |
| 748 | $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN" |
| 749 | } |
| 750 | |
| 751 | $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -TimeoutSec 30 |
| 752 | $sha = $response.sha |
| 753 | |
| 754 | if ($sha) { |
| 755 | return "Pin to SHA: uses: $name@$sha # $version" |
| 756 | } |
| 757 | } |
| 758 | |
| 759 | default { |
| 760 | return "Research and pin to specific commit SHA or content hash for $type dependencies" |
| 761 | } |
| 762 | } |
| 763 | } |
| 764 | catch { |
| 765 | Write-SecurityLog -CIAnnotation "Could not generate automatic remediation for $($Violation.Name): $($_.Exception.Message)" -Level Warning |
| 766 | } |
| 767 | |
| 768 | return "Manually research and pin to immutable reference" |
| 769 | } |
| 770 | |
| 771 | function Get-ComplianceReportData { |
| 772 | <# |
| 773 | .SYNOPSIS |
| 774 | Generates a comprehensive compliance report. |
| 775 | #> |
| 776 | [CmdletBinding()] |
| 777 | param( |
| 778 | [DependencyViolation[]]$Violations, |
| 779 | [hashtable[]]$ScannedFiles, |
| 780 | [string]$ScanPath, |
| 781 | [Parameter(Mandatory)] |
| 782 | [int]$TotalDependencies, |
| 783 | [switch]$Remediate |
| 784 | ) |
| 785 | |
| 786 | $report = [ComplianceReport]::new() |
| 787 | $report.ScanPath = $ScanPath |
| 788 | $report.ScannedFiles = $ScannedFiles.Count |
| 789 | $report.Violations = $Violations |
| 790 | |
| 791 | # Calculate metrics using true dependency counts from scanners |
| 792 | $report.TotalDependencies = $TotalDependencies |
| 793 | $report.UnpinnedDependencies = @($Violations).Count |
| 794 | $report.PinnedDependencies = $TotalDependencies - $report.UnpinnedDependencies |
| 795 | $report.CalculateScore() |
| 796 | |
| 797 | # Generate summary by type |
| 798 | $report.Summary = @{} |
| 799 | foreach ($type in @($Violations | Group-Object Type)) { |
| 800 | $report.Summary[$type.Name] = @{ |
| 801 | Total = $type.Count |
| 802 | High = @($type.Group | Where-Object { $_.Severity -eq 'High' }).Count |
| 803 | Medium = @($type.Group | Where-Object { $_.Severity -eq 'Medium' }).Count |
| 804 | Low = @($type.Group | Where-Object { $_.Severity -eq 'Low' }).Count |
| 805 | } |
| 806 | } |
| 807 | |
| 808 | # Add metadata |
| 809 | $report.Metadata = @{ |
| 810 | PowerShellVersion = $PSVersionTable.PSVersion.ToString() |
| 811 | Platform = $PSVersionTable.Platform |
| 812 | ScanTimestamp = $report.Timestamp.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') |
| 813 | IncludedTypes = $IncludeTypes |
| 814 | ExcludedPaths = $ExcludePaths |
| 815 | RemediationEnabled = $Remediate.IsPresent |
| 816 | ComplianceThreshold = $Threshold |
| 817 | } |
| 818 | |
| 819 | return $report |
| 820 | } |
| 821 | |
| 822 | function Export-ComplianceReport { |
| 823 | <# |
| 824 | .SYNOPSIS |
| 825 | Exports compliance report in specified format. |
| 826 | #> |
| 827 | [CmdletBinding()] |
| 828 | param( |
| 829 | # Use duck typing to avoid class type collision during code coverage instrumentation |
| 830 | $Report, |
| 831 | [string]$Format, |
| 832 | [string]$OutputPath |
| 833 | ) |
| 834 | |
| 835 | # Validate required properties on duck-typed $Report parameter (ComplianceReport schema) |
| 836 | $requiredProperties = @('ComplianceScore', 'Violations', 'TotalDependencies', 'UnpinnedDependencies', 'Metadata') |
| 837 | foreach ($prop in $requiredProperties) { |
| 838 | if ($null -eq $Report.PSObject.Properties[$prop]) { |
| 839 | throw "Report object missing required property: $prop" |
| 840 | } |
| 841 | } |
| 842 | |
| 843 | # Ensure parent directory exists |
| 844 | $parentDir = Split-Path -Path $OutputPath -Parent |
| 845 | if ($parentDir -and -not (Test-Path $parentDir)) { |
| 846 | New-Item -ItemType Directory -Path $parentDir -Force | Out-Null |
| 847 | } |
| 848 | |
| 849 | switch ($Format.ToLower()) { |
| 850 | 'json' { |
| 851 | $Report | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 852 | } |
| 853 | |
| 854 | 'sarif' { |
| 855 | $sarif = @{ |
| 856 | version = "2.1.0" |
| 857 | "`$schema" = "https://json.schemastore.org/sarif-2.1.0.json" |
| 858 | runs = @(@{ |
| 859 | tool = @{ |
| 860 | driver = @{ |
| 861 | name = "dependency-pinning-analyzer" |
| 862 | version = "1.0.0" |
| 863 | informationUri = "https://github.com/microsoft/hve-core" |
| 864 | } |
| 865 | } |
| 866 | results = @($Report.Violations | ForEach-Object { |
| 867 | @{ |
| 868 | ruleId = "dependency-not-pinned" |
| 869 | level = switch ($_.Severity) { 'High' { 'error' } 'Medium' { 'warning' } default { 'note' } } |
| 870 | message = @{ text = $_.Description } |
| 871 | locations = @(@{ |
| 872 | physicalLocation = @{ |
| 873 | artifactLocation = @{ uri = $_.File } |
| 874 | region = @{ startLine = $_.Line } |
| 875 | } |
| 876 | }) |
| 877 | properties = @{ |
| 878 | dependencyName = $_.Name |
| 879 | currentVersion = $_.Version |
| 880 | remediation = $_.Remediation |
| 881 | } |
| 882 | } |
| 883 | }) |
| 884 | }) |
| 885 | } |
| 886 | $sarif | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 887 | } |
| 888 | |
| 889 | 'csv' { |
| 890 | $Report.Violations | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 |
| 891 | } |
| 892 | |
| 893 | 'markdown' { |
| 894 | $markdown = @" |
| 895 | # Dependency Pinning Compliance Report |
| 896 | |
| 897 | **Scan Date:** $($Report.Timestamp.ToString('yyyy-MM-dd HH:mm:ss')) |
| 898 | **Scan Path:** $($Report.ScanPath) |
| 899 | **Compliance Score:** $($Report.ComplianceScore)% |
| 900 | |
| 901 | ## Summary |
| 902 | |
| 903 | | Metric | Count | |
| 904 | |--------|--------| |
| 905 | | Total Files Scanned | $($Report.ScannedFiles) | |
| 906 | | Total Dependencies | $($Report.TotalDependencies) | |
| 907 | | Pinned Dependencies | $($Report.PinnedDependencies) | |
| 908 | | Unpinned Dependencies | $($Report.UnpinnedDependencies) | |
| 909 | |
| 910 | ## Violations by Type |
| 911 | |
| 912 | "@ |
| 913 | foreach ($type in $Report.Summary.Keys) { |
| 914 | $summary = $Report.Summary[$type] |
| 915 | $markdown += @" |
| 916 | |
| 917 | ### $type |
| 918 | - **Total:** $($summary.Total) |
| 919 | - **High Severity:** $($summary.High) |
| 920 | - **Medium Severity:** $($summary.Medium) |
| 921 | - **Low Severity:** $($summary.Low) |
| 922 | |
| 923 | "@ |
| 924 | } |
| 925 | |
| 926 | if ($Report.Violations.Count -gt 0) { |
| 927 | $markdown += @" |
| 928 | |
| 929 | ## Detailed Violations |
| 930 | |
| 931 | | File | Line | Type | Dependency | Current Version | Severity | Remediation | |
| 932 | |------|------|------|------------|----------------|----------|-------------| |
| 933 | "@ |
| 934 | foreach ($violation in $Report.Violations) { |
| 935 | $markdown += "|$($violation.File)|$($violation.Line)|$($violation.Type)|$($violation.Name)|$($violation.Version)|$($violation.Severity)|$($violation.Remediation)|`n" |
| 936 | } |
| 937 | } |
| 938 | |
| 939 | $markdown | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 940 | } |
| 941 | |
| 942 | 'table' { |
| 943 | # Display formatted table to console and save simple text format |
| 944 | if ($Report.Violations.Count -gt 0) { |
| 945 | $Report.Violations | Format-Table -Property File, Line, Type, Name, Version, Severity -AutoSize | Out-File -FilePath $OutputPath -Encoding UTF8 -Width 200 |
| 946 | } |
| 947 | else { |
| 948 | "No dependency pinning violations found." | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 949 | } |
| 950 | } |
| 951 | } |
| 952 | |
| 953 | Write-SecurityLog -CIAnnotation "Compliance report exported to: $OutputPath" -Level Success |
| 954 | } |
| 955 | |
| 956 | function Export-CICDArtifact { |
| 957 | <# |
| 958 | .SYNOPSIS |
| 959 | Exports compliance report as CI/CD artifacts for both GitHub Actions and Azure DevOps. |
| 960 | #> |
| 961 | [CmdletBinding()] |
| 962 | param( |
| 963 | [ComplianceReport]$Report, |
| 964 | [string]$ReportPath |
| 965 | ) |
| 966 | |
| 967 | Write-SecurityLog -CIAnnotation "Preparing compliance artifacts for CI/CD systems..." -Level Info |
| 968 | |
| 969 | $platform = Get-CIPlatform |
| 970 | Write-SecurityLog -CIAnnotation "Detected $platform environment - setting up artifacts" -Level Info |
| 971 | |
| 972 | # Set CI outputs (works for both GitHub Actions and Azure DevOps) |
| 973 | Set-CIOutput -Name 'dependency-report' -Value $ReportPath -IsOutput |
| 974 | Set-CIOutput -Name 'compliance-score' -Value $Report.ComplianceScore -IsOutput |
| 975 | Set-CIOutput -Name 'unpinned-count' -Value $Report.UnpinnedDependencies -IsOutput |
| 976 | |
| 977 | # Create summary content |
| 978 | $summaryContent = @" |
| 979 | # 📌 Dependency Pinning Analysis |
| 980 | |
| 981 | **Compliance Score:** $($Report.ComplianceScore)% |
| 982 | **Unpinned Dependencies:** $($Report.UnpinnedDependencies) |
| 983 | **Total Dependencies Scanned:** $($Report.TotalDependencies) |
| 984 | |
| 985 | $(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!" }) |
| 986 | "@ |
| 987 | |
| 988 | # Write step summary |
| 989 | Write-CIStepSummary -Content $summaryContent |
| 990 | |
| 991 | # Publish artifact |
| 992 | Publish-CIArtifact -Path $ReportPath -Name 'dependency-pinning-report' -ContainerFolder 'dependency-pinning' |
| 993 | |
| 994 | # Set up local artifact directory for GitHub Actions upload-artifact action |
| 995 | if ($platform -eq 'github') { |
| 996 | $artifactDir = Join-Path $PWD "dependency-pinning-artifacts" |
| 997 | New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null |
| 998 | Copy-Item -Path $ReportPath -Destination $artifactDir -Force |
| 999 | } |
| 1000 | |
| 1001 | Write-SecurityLog -CIAnnotation "Compliance artifacts prepared for CI/CD consumption" -Level Success |
| 1002 | } |
| 1003 | |
| 1004 | function Invoke-DependencyPinningAnalysis { |
| 1005 | <# |
| 1006 | .SYNOPSIS |
| 1007 | Orchestrates dependency pinning compliance analysis. |
| 1008 | #> |
| 1009 | [CmdletBinding()] |
| 1010 | [OutputType([void])] |
| 1011 | param( |
| 1012 | [Parameter()] |
| 1013 | [string]$Path = ".", |
| 1014 | |
| 1015 | [Parameter()] |
| 1016 | [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads,workflow-npm-commands", |
| 1017 | |
| 1018 | [Parameter()] |
| 1019 | [string]$ExcludePaths = "", |
| 1020 | |
| 1021 | [Parameter()] |
| 1022 | [string]$Format = 'json', |
| 1023 | |
| 1024 | [Parameter()] |
| 1025 | [string]$OutputPath = 'logs/dependency-pinning-results.json', |
| 1026 | |
| 1027 | [Parameter()] |
| 1028 | [switch]$FailOnUnpinned, |
| 1029 | |
| 1030 | [Parameter()] |
| 1031 | [int]$Threshold = 95, |
| 1032 | |
| 1033 | [Parameter()] |
| 1034 | [switch]$Remediate |
| 1035 | ) |
| 1036 | |
| 1037 | Write-SecurityLog -CIAnnotation "Starting dependency pinning compliance analysis..." -Level Info |
| 1038 | Write-SecurityLog -CIAnnotation "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info |
| 1039 | Write-SecurityLog -CIAnnotation "Platform: $($PSVersionTable.Platform)" -Level Info |
| 1040 | |
| 1041 | # Parse include types and exclude paths |
| 1042 | $typesToCheck = $IncludeTypes.Split(',') | ForEach-Object { $_.Trim() } |
| 1043 | $excludePatterns = if ($ExcludePaths) { $ExcludePaths.Split(',') | ForEach-Object { $_.Trim() } } else { @() } |
| 1044 | |
| 1045 | Write-SecurityLog -CIAnnotation "Scanning path: $Path" -Level Info |
| 1046 | Write-SecurityLog -CIAnnotation "Include types: $($typesToCheck -join ', ')" -Level Info |
| 1047 | if ($excludePatterns) { Write-SecurityLog -CIAnnotation "Exclude patterns: $($excludePatterns -join ', ')" -Level Info } |
| 1048 | |
| 1049 | # Discover files to scan |
| 1050 | $filesToScan = @(Get-FilesToScan -ScanPath $Path -Types $typesToCheck -ExcludePatterns $excludePatterns) |
| 1051 | Write-SecurityLog -CIAnnotation "Found $(@($filesToScan).Count) files to scan" -Level Info |
| 1052 | |
| 1053 | # Scan for violations |
| 1054 | $allViolations = @() |
| 1055 | $totalDependencyCount = 0 |
| 1056 | foreach ($fileInfo in $filesToScan) { |
| 1057 | Write-SecurityLog -CIAnnotation "Scanning: $($fileInfo.RelativePath)" -Level Info |
| 1058 | $scanResult = Get-DependencyViolation -FileInfo $fileInfo |
| 1059 | $totalDependencyCount += $scanResult.TotalCount |
| 1060 | $violations = @($scanResult.Violations) |
| 1061 | |
| 1062 | # Add remediation suggestions |
| 1063 | foreach ($violation in $violations) { |
| 1064 | $violation.Remediation = Get-RemediationSuggestion -Violation $violation -Remediate:$Remediate |
| 1065 | } |
| 1066 | |
| 1067 | $allViolations += $violations |
| 1068 | } |
| 1069 | |
| 1070 | Write-SecurityLog -CIAnnotation "Found $(@($allViolations).Count) dependency pinning violations" -Level Info |
| 1071 | |
| 1072 | # Emit per-violation CI annotations and console output |
| 1073 | if ($allViolations.Count -gt 0) { |
| 1074 | Write-Host "`n❌ Found $($allViolations.Count) unpinned dependencies:" -ForegroundColor Red |
| 1075 | $groupedByFile = $allViolations | Group-Object -Property File |
| 1076 | foreach ($fileGroup in $groupedByFile) { |
| 1077 | Write-Host "`n📄 $($fileGroup.Name)" -ForegroundColor Cyan |
| 1078 | foreach ($dep in $fileGroup.Group) { |
| 1079 | $annotationLevel = switch ($dep.Severity) { |
| 1080 | 'High' { 'Error' } |
| 1081 | 'Medium' { 'Warning' } |
| 1082 | default { 'Notice' } |
| 1083 | } |
| 1084 | $icon = switch ($dep.Severity) { |
| 1085 | 'High' { '❌' } |
| 1086 | 'Medium' { '⚠️' } |
| 1087 | default { 'ℹ️' } |
| 1088 | } |
| 1089 | $color = switch ($dep.Severity) { |
| 1090 | 'High' { 'Red' } |
| 1091 | 'Medium' { 'Yellow' } |
| 1092 | default { 'Cyan' } |
| 1093 | } |
| 1094 | Write-Host " $icon [$($dep.Severity)] $($dep.Name)@$($dep.Version): $($dep.Description) (Line $($dep.Line))" -ForegroundColor $color |
| 1095 | Write-CIAnnotation ` |
| 1096 | -Message "[$($dep.ViolationType)] $($dep.Name): $($dep.Description)" ` |
| 1097 | -Level $annotationLevel ` |
| 1098 | -File $dep.File ` |
| 1099 | -Line $dep.Line |
| 1100 | } |
| 1101 | } |
| 1102 | } |
| 1103 | else { |
| 1104 | Write-Host "`n✅ All dependencies are properly pinned." -ForegroundColor Green |
| 1105 | } |
| 1106 | |
| 1107 | # Generate compliance report |
| 1108 | $report = Get-ComplianceReportData -Violations $allViolations -ScannedFiles $filesToScan -ScanPath $Path -TotalDependencies $totalDependencyCount -Remediate:$Remediate |
| 1109 | |
| 1110 | # Export report |
| 1111 | Export-ComplianceReport -Report $report -Format $Format -OutputPath $OutputPath |
| 1112 | |
| 1113 | # Export CI/CD artifacts |
| 1114 | Export-CICDArtifact -Report $report -ReportPath $OutputPath |
| 1115 | |
| 1116 | # Display summary |
| 1117 | Write-SecurityLog -CIAnnotation "Compliance Analysis Complete!" -Level Success |
| 1118 | Write-SecurityLog -CIAnnotation "Compliance Score: $($report.ComplianceScore)%" -Level Info |
| 1119 | Write-SecurityLog -CIAnnotation "Total Dependencies: $($report.TotalDependencies)" -Level Info |
| 1120 | Write-SecurityLog -CIAnnotation "Unpinned Dependencies: $($report.UnpinnedDependencies)" -Level Info |
| 1121 | |
| 1122 | if ($report.UnpinnedDependencies -gt 0) { |
| 1123 | Write-SecurityLog -CIAnnotation "$($report.UnpinnedDependencies) dependencies require pinning for security compliance" -Level Warning |
| 1124 | |
| 1125 | # Check threshold compliance |
| 1126 | if ($report.ComplianceScore -lt $Threshold) { |
| 1127 | Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% is below threshold $Threshold%" -Level Error |
| 1128 | |
| 1129 | if ($FailOnUnpinned) { |
| 1130 | Write-SecurityLog -CIAnnotation "Failing build due to compliance threshold violation (-FailOnUnpinned enabled)" -Level Error |
| 1131 | throw "Compliance score $($report.ComplianceScore)% is below threshold $Threshold% (-FailOnUnpinned enabled)" |
| 1132 | } |
| 1133 | else { |
| 1134 | Write-SecurityLog -CIAnnotation "Threshold violation detected but continuing (soft-fail mode)" -Level Warning |
| 1135 | } |
| 1136 | } |
| 1137 | else { |
| 1138 | Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% meets threshold $Threshold%" -Level Info |
| 1139 | } |
| 1140 | } |
| 1141 | else { |
| 1142 | Write-SecurityLog -CIAnnotation "All dependencies are properly pinned! ✅ (100% compliance, exceeds $Threshold% threshold)" -Level Success |
| 1143 | } |
| 1144 | } |
| 1145 | |
| 1146 | #endregion Functions |
| 1147 | |
| 1148 | #region Main Execution |
| 1149 | if ($MyInvocation.InvocationName -ne '.') { |
| 1150 | try { |
| 1151 | Invoke-DependencyPinningAnalysis ` |
| 1152 | -Path $Path ` |
| 1153 | -IncludeTypes $IncludeTypes ` |
| 1154 | -ExcludePaths $ExcludePaths ` |
| 1155 | -Format $Format ` |
| 1156 | -OutputPath $OutputPath ` |
| 1157 | -FailOnUnpinned:$FailOnUnpinned ` |
| 1158 | -Threshold $Threshold ` |
| 1159 | -Remediate:$Remediate |
| 1160 | exit 0 |
| 1161 | } |
| 1162 | catch { |
| 1163 | Write-Error -ErrorAction Continue "Test-DependencyPinning failed: $($_.Exception.Message)" |
| 1164 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 1165 | exit 1 |
| 1166 | } |
| 1167 | } |
| 1168 | #endregion Main Execution |
| 1169 | |