microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3e2dac35fe558d8815fb3594a2e87238732f8ecd

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-DependencyPinning.ps1

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