microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/ado-backlog-discovery

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-DependencyPinning.ps1

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