microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-DependencyPinning.ps1

1177lines · 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
88using module ./Modules/SecurityClasses.psm1
89
90[CmdletBinding()]
91param(
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
122Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
123Import-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
181function 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
208function 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
251function 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
335function 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
401function 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
493function 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 $true for local-path protocol references (file:, link:) because
500 they resolve to in-repo paths rather than registry downloads and cannot
501 be version- or SHA-pinned.
502 Returns $false for ranges, wildcards, URLs, tags, and git references.
503 #>
504 [CmdletBinding()]
505 param(
506 [Parameter(Mandatory)]
507 [string]$Version
508 )
509
510 # Local-path protocol references resolve to in-repo paths, not registry
511 # downloads, so they pose no supply-chain pinning risk.
512 if ($Version -match '^(file|link):') {
513 return $true
514 }
515
516 # Reject range operators, wildcards, URLs, git refs, and tags like "latest"
517 if ($Version -match '^[~^>=<*|]' -or
518 $Version -match '://' -or
519 $Version -match '\.git\b' -or
520 $Version -match '\s*\|\|' -or
521 $Version -match '^\w+$' -and $Version -notmatch '^\d') {
522 return $false
523 }
524
525 # Accept exact semver: major.minor.patch with optional prerelease/build metadata
526 return $Version -match '^\d+\.\d+\.\d+(-[a-zA-Z0-9._-]+)?(\+[a-zA-Z0-9._-]+)?$'
527}
528
529function Get-FilesToScan {
530 <#
531 .SYNOPSIS
532 Discovers files to scan based on dependency type patterns.
533 #>
534 [CmdletBinding()]
535 param(
536 [string]$ScanPath,
537 [string[]]$Types,
538 [string[]]$ExcludePatterns
539 )
540
541 $allFiles = @()
542
543 foreach ($type in $Types) {
544 if ($DependencyPatterns.ContainsKey($type)) {
545 $patterns = $DependencyPatterns[$type].FilePatterns
546
547 foreach ($pattern in $patterns) {
548 try {
549 # Decompose glob into a directory prefix and a leaf filename filter.
550 # Get-ChildItem -Path does not expand ** globs on all platforms,
551 # so we strip the ** segments and use -Recurse with -Filter instead.
552 $segments = $pattern -split '[/\\]'
553 $leafFilter = $segments[-1]
554 $dirSegments = $segments[0..($segments.Length - 2)] | Where-Object { $_ -ne '**' }
555
556 if ($dirSegments.Count -gt 0) {
557 $basePath = Join-Path $ScanPath ($dirSegments -join [System.IO.Path]::DirectorySeparatorChar)
558 }
559 else {
560 $basePath = $ScanPath
561 }
562
563 if (-not (Test-Path -Path $basePath -PathType Container)) {
564 continue
565 }
566
567 $files = Get-ChildItem -Path $basePath -Filter $leafFilter -Recurse -File -ErrorAction SilentlyContinue
568
569 # Merge type-specific exclude patterns with caller-provided patterns
570 $mergedExcludes = @()
571 if ($ExcludePatterns) {
572 $mergedExcludes += @($ExcludePatterns)
573 }
574 if ($DependencyPatterns[$type].ContainsKey('ExcludePatterns')) {
575 $mergedExcludes += $DependencyPatterns[$type].ExcludePatterns
576 }
577
578 if ($mergedExcludes) {
579 foreach ($exclude in $mergedExcludes) {
580 $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
581 }
582 }
583
584 $allFiles += $files | ForEach-Object {
585 @{
586 Path = $_.FullName
587 Type = $type
588 RelativePath = [System.IO.Path]::GetRelativePath($ScanPath, $_.FullName)
589 }
590 }
591 }
592 catch {
593 Write-SecurityLog -CIAnnotation "Error scanning for $type files with pattern $pattern`: $($_.Exception.Message)" -Level Warning
594 }
595 }
596 }
597 }
598
599 return $allFiles | Sort-Object Path -Unique
600}
601
602function Test-SHAPinning {
603 <#
604 .SYNOPSIS
605 Tests if a version reference is properly SHA-pinned.
606 #>
607 [CmdletBinding()]
608 param(
609 [string]$Version,
610 [string]$Type
611 )
612
613 if ($DependencyPatterns.ContainsKey($Type) -and $DependencyPatterns[$Type].SHAPattern) {
614 $shaPattern = $DependencyPatterns[$Type].SHAPattern
615 return $Version -match $shaPattern
616 }
617
618 return $false
619}
620
621function Get-DependencyViolation {
622 <#
623 .SYNOPSIS
624 Scans a file for dependency pinning violations.
625 #>
626 [CmdletBinding()]
627 param(
628 [hashtable]$FileInfo
629 )
630
631 $violations = @()
632 $filePath = $FileInfo.Path
633 $fileType = $FileInfo.Type
634
635 if (!(Test-Path $filePath)) {
636 return @{ TotalCount = 0; Violations = @() }
637 }
638
639 # Check if this type uses a validation function instead of regex patterns
640 if ($null -ne $DependencyPatterns[$fileType].ValidationFunc) {
641 $funcName = $DependencyPatterns[$fileType].ValidationFunc
642 $scanResult = & $funcName -FileInfo $FileInfo
643
644 if ($null -eq $scanResult) {
645 return @{ TotalCount = 0; Violations = @() }
646 }
647
648 foreach ($v in @($scanResult.Violations)) {
649 if ($null -eq $v) {
650 continue
651 }
652
653 if (-not ($v -is [DependencyViolation])) {
654 $actualType = $v.GetType().FullName
655 throw "Validation function '$funcName' must return [DependencyViolation] objects, got '$actualType'."
656 }
657
658 if (-not $v.File) {
659 $v.File = $FileInfo.RelativePath
660 }
661
662 if ($v.Line -lt 1) {
663 $v.Line = 1
664 }
665
666 if (-not $v.Type) {
667 $v.Type = $fileType
668 }
669 }
670
671 return $scanResult
672 }
673
674 try {
675 $content = Get-Content -Path $filePath -Raw
676 $lines = Get-Content -Path $filePath
677
678 $patterns = $DependencyPatterns[$fileType].VersionPatterns
679 $totalCount = 0
680
681 foreach ($patternInfo in $patterns) {
682 $pattern = $patternInfo.Pattern
683 $description = $patternInfo.Description
684
685 $regexMatches = [regex]::Matches($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
686 $totalCount += @($regexMatches).Count
687
688 foreach ($match in $regexMatches) {
689 # Find line number
690 $lineNumber = 1
691 $position = $match.Index
692 for ($i = 0; $i -lt $position; $i++) {
693 if ($content[$i] -eq "`n") {
694 $lineNumber++
695 }
696 }
697
698 # Extract dependency information
699 $dependencyName = $match.Groups[1].Value
700 $version = $match.Groups[2].Value
701
702 # Check if properly pinned
703 if (!(Test-SHAPinning -Version $version -Type $fileType)) {
704 $violation = [DependencyViolation]::new()
705 $violation.File = $FileInfo.RelativePath
706 $violation.Line = $lineNumber
707 $violation.Type = $fileType
708 $violation.Name = $dependencyName
709 $violation.Version = $version
710 $violation.CurrentRef = $match.Value
711 $violation.Description = "Unpinned dependency: $description"
712 $violation.Severity = if ($fileType -eq 'github-actions') { 'High' } else { 'Medium' }
713 $violation.ViolationType = 'Unpinned'
714 $violation.Metadata['PatternDescription'] = $description
715 $violation.Metadata['LineContent'] = $lines[$lineNumber - 1]
716
717 $violations += $violation
718 }
719 }
720 }
721 }
722 catch {
723 Write-SecurityLog -CIAnnotation "Error scanning file $filePath`: $($_.Exception.Message)" -Level Warning
724 }
725
726 return @{ TotalCount = $totalCount; Violations = $violations }
727}
728
729function Get-RemediationSuggestion {
730 <#
731 .SYNOPSIS
732 Generates remediation suggestions for unpinned dependencies.
733 #>
734 [CmdletBinding()]
735 param(
736 [DependencyViolation]$Violation,
737
738 [switch]$Remediate
739 )
740
741 $type = $Violation.Type
742 $name = $Violation.Name
743 $version = $Violation.Version
744
745 if (!$Remediate) {
746 return "Enable -Remediate flag for specific SHA suggestions"
747 }
748
749 try {
750 switch ($type) {
751 'github-actions' {
752 # For GitHub Actions, resolve tag to commit SHA
753 $apiUrl = "$script:GitHubApiBase/repos/$name/commits/$version"
754 $headers = @{}
755
756 if ($env:GITHUB_TOKEN) {
757 $headers['Authorization'] = "Bearer $env:GITHUB_TOKEN"
758 }
759
760 $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -TimeoutSec 30
761 $sha = $response.sha
762
763 if ($sha) {
764 return "Pin to SHA: uses: $name@$sha # $version"
765 }
766 }
767
768 default {
769 return "Research and pin to specific commit SHA or content hash for $type dependencies"
770 }
771 }
772 }
773 catch {
774 Write-SecurityLog -CIAnnotation "Could not generate automatic remediation for $($Violation.Name): $($_.Exception.Message)" -Level Warning
775 }
776
777 return "Manually research and pin to immutable reference"
778}
779
780function Get-ComplianceReportData {
781 <#
782 .SYNOPSIS
783 Generates a comprehensive compliance report.
784 #>
785 [CmdletBinding()]
786 param(
787 [DependencyViolation[]]$Violations,
788 [hashtable[]]$ScannedFiles,
789 [string]$ScanPath,
790 [Parameter(Mandatory)]
791 [int]$TotalDependencies,
792 [switch]$Remediate
793 )
794
795 $report = [ComplianceReport]::new()
796 $report.ScanPath = $ScanPath
797 $report.ScannedFiles = $ScannedFiles.Count
798 $report.Violations = $Violations
799
800 # Calculate metrics using true dependency counts from scanners
801 $report.TotalDependencies = $TotalDependencies
802 $report.UnpinnedDependencies = @($Violations).Count
803 $report.PinnedDependencies = $TotalDependencies - $report.UnpinnedDependencies
804 $report.CalculateScore()
805
806 # Generate summary by type
807 $report.Summary = @{}
808 foreach ($type in @($Violations | Group-Object Type)) {
809 $report.Summary[$type.Name] = @{
810 Total = $type.Count
811 High = @($type.Group | Where-Object { $_.Severity -eq 'High' }).Count
812 Medium = @($type.Group | Where-Object { $_.Severity -eq 'Medium' }).Count
813 Low = @($type.Group | Where-Object { $_.Severity -eq 'Low' }).Count
814 }
815 }
816
817 # Add metadata
818 $report.Metadata = @{
819 PowerShellVersion = $PSVersionTable.PSVersion.ToString()
820 Platform = $PSVersionTable.Platform
821 ScanTimestamp = $report.Timestamp.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ')
822 IncludedTypes = $IncludeTypes
823 ExcludedPaths = $ExcludePaths
824 RemediationEnabled = $Remediate.IsPresent
825 ComplianceThreshold = $Threshold
826 }
827
828 return $report
829}
830
831function Export-ComplianceReport {
832 <#
833 .SYNOPSIS
834 Exports compliance report in specified format.
835 #>
836 [CmdletBinding()]
837 param(
838 # Use duck typing to avoid class type collision during code coverage instrumentation
839 $Report,
840 [string]$Format,
841 [string]$OutputPath
842 )
843
844 # Validate required properties on duck-typed $Report parameter (ComplianceReport schema)
845 $requiredProperties = @('ComplianceScore', 'Violations', 'TotalDependencies', 'UnpinnedDependencies', 'Metadata')
846 foreach ($prop in $requiredProperties) {
847 if ($null -eq $Report.PSObject.Properties[$prop]) {
848 throw "Report object missing required property: $prop"
849 }
850 }
851
852 # Ensure parent directory exists
853 $parentDir = Split-Path -Path $OutputPath -Parent
854 if ($parentDir -and -not (Test-Path $parentDir)) {
855 New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
856 }
857
858 switch ($Format.ToLower()) {
859 'json' {
860 $Report | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8
861 }
862
863 'sarif' {
864 $sarif = @{
865 version = "2.1.0"
866 "`$schema" = "https://json.schemastore.org/sarif-2.1.0.json"
867 runs = @(@{
868 tool = @{
869 driver = @{
870 name = "dependency-pinning-analyzer"
871 version = "1.0.0"
872 informationUri = "https://github.com/microsoft/hve-core"
873 }
874 }
875 results = @($Report.Violations | ForEach-Object {
876 @{
877 ruleId = "dependency-not-pinned"
878 level = switch ($_.Severity) { 'High' { 'error' } 'Medium' { 'warning' } default { 'note' } }
879 message = @{ text = $_.Description }
880 locations = @(@{
881 physicalLocation = @{
882 artifactLocation = @{ uri = $_.File }
883 region = @{ startLine = $_.Line }
884 }
885 })
886 properties = @{
887 dependencyName = $_.Name
888 currentVersion = $_.Version
889 remediation = $_.Remediation
890 }
891 }
892 })
893 })
894 }
895 $sarif | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8
896 }
897
898 'csv' {
899 $Report.Violations | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
900 }
901
902 'markdown' {
903 $markdown = @"
904# Dependency Pinning Compliance Report
905
906**Scan Date:** $($Report.Timestamp.ToString('yyyy-MM-dd HH:mm:ss'))
907**Scan Path:** $($Report.ScanPath)
908**Compliance Score:** $($Report.ComplianceScore)%
909
910## Summary
911
912| Metric | Count |
913|--------|--------|
914| Total Files Scanned | $($Report.ScannedFiles) |
915| Total Dependencies | $($Report.TotalDependencies) |
916| Pinned Dependencies | $($Report.PinnedDependencies) |
917| Unpinned Dependencies | $($Report.UnpinnedDependencies) |
918
919## Violations by Type
920
921"@
922 foreach ($type in $Report.Summary.Keys) {
923 $summary = $Report.Summary[$type]
924 $markdown += @"
925
926### $type
927- **Total:** $($summary.Total)
928- **High Severity:** $($summary.High)
929- **Medium Severity:** $($summary.Medium)
930- **Low Severity:** $($summary.Low)
931
932"@
933 }
934
935 if ($Report.Violations.Count -gt 0) {
936 $markdown += @"
937
938## Detailed Violations
939
940| File | Line | Type | Dependency | Current Version | Severity | Remediation |
941|------|------|------|------------|----------------|----------|-------------|
942"@
943 foreach ($violation in $Report.Violations) {
944 $markdown += "|$($violation.File)|$($violation.Line)|$($violation.Type)|$($violation.Name)|$($violation.Version)|$($violation.Severity)|$($violation.Remediation)|`n"
945 }
946 }
947
948 $markdown | Out-File -FilePath $OutputPath -Encoding UTF8
949 }
950
951 'table' {
952 # Display formatted table to console and save simple text format
953 if ($Report.Violations.Count -gt 0) {
954 $Report.Violations | Format-Table -Property File, Line, Type, Name, Version, Severity -AutoSize | Out-File -FilePath $OutputPath -Encoding UTF8 -Width 200
955 }
956 else {
957 "No dependency pinning violations found." | Out-File -FilePath $OutputPath -Encoding UTF8
958 }
959 }
960 }
961
962 Write-SecurityLog -CIAnnotation "Compliance report exported to: $OutputPath" -Level Success
963}
964
965function Export-CICDArtifact {
966 <#
967 .SYNOPSIS
968 Exports compliance report as CI/CD artifacts for both GitHub Actions and Azure DevOps.
969 #>
970 [CmdletBinding()]
971 param(
972 [ComplianceReport]$Report,
973 [string]$ReportPath
974 )
975
976 Write-SecurityLog -CIAnnotation "Preparing compliance artifacts for CI/CD systems..." -Level Info
977
978 $platform = Get-CIPlatform
979 Write-SecurityLog -CIAnnotation "Detected $platform environment - setting up artifacts" -Level Info
980
981 # Set CI outputs (works for both GitHub Actions and Azure DevOps)
982 Set-CIOutput -Name 'dependency-report' -Value $ReportPath -IsOutput
983 Set-CIOutput -Name 'compliance-score' -Value $Report.ComplianceScore -IsOutput
984 Set-CIOutput -Name 'unpinned-count' -Value $Report.UnpinnedDependencies -IsOutput
985
986 # Create summary content
987 $summaryContent = @"
988# 📌 Dependency Pinning Analysis
989
990**Compliance Score:** $($Report.ComplianceScore)%
991**Unpinned Dependencies:** $($Report.UnpinnedDependencies)
992**Total Dependencies Scanned:** $($Report.TotalDependencies)
993
994$(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!" })
995"@
996
997 # Write step summary
998 Write-CIStepSummary -Content $summaryContent
999
1000 # Publish artifact
1001 Publish-CIArtifact -Path $ReportPath -Name 'dependency-pinning-report' -ContainerFolder 'dependency-pinning'
1002
1003 # Set up local artifact directory for GitHub Actions upload-artifact action
1004 if ($platform -eq 'github') {
1005 $artifactDir = Join-Path $PWD "dependency-pinning-artifacts"
1006 New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null
1007 Copy-Item -Path $ReportPath -Destination $artifactDir -Force
1008 }
1009
1010 Write-SecurityLog -CIAnnotation "Compliance artifacts prepared for CI/CD consumption" -Level Success
1011}
1012
1013function Invoke-DependencyPinningAnalysis {
1014 <#
1015 .SYNOPSIS
1016 Orchestrates dependency pinning compliance analysis.
1017 #>
1018 [CmdletBinding()]
1019 [OutputType([void])]
1020 param(
1021 [Parameter()]
1022 [string]$Path = ".",
1023
1024 [Parameter()]
1025 [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads,workflow-npm-commands",
1026
1027 [Parameter()]
1028 [string]$ExcludePaths = "",
1029
1030 [Parameter()]
1031 [string]$Format = 'json',
1032
1033 [Parameter()]
1034 [string]$OutputPath = 'logs/dependency-pinning-results.json',
1035
1036 [Parameter()]
1037 [switch]$FailOnUnpinned,
1038
1039 [Parameter()]
1040 [int]$Threshold = 95,
1041
1042 [Parameter()]
1043 [switch]$Remediate
1044 )
1045
1046 Write-SecurityLog -CIAnnotation "Starting dependency pinning compliance analysis..." -Level Info
1047 Write-SecurityLog -CIAnnotation "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info
1048 Write-SecurityLog -CIAnnotation "Platform: $($PSVersionTable.Platform)" -Level Info
1049
1050 # Parse include types and exclude paths
1051 $typesToCheck = $IncludeTypes.Split(',') | ForEach-Object { $_.Trim() }
1052 $excludePatterns = if ($ExcludePaths) { $ExcludePaths.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
1053
1054 Write-SecurityLog -CIAnnotation "Scanning path: $Path" -Level Info
1055 Write-SecurityLog -CIAnnotation "Include types: $($typesToCheck -join ', ')" -Level Info
1056 if ($excludePatterns) { Write-SecurityLog -CIAnnotation "Exclude patterns: $($excludePatterns -join ', ')" -Level Info }
1057
1058 # Discover files to scan
1059 $filesToScan = @(Get-FilesToScan -ScanPath $Path -Types $typesToCheck -ExcludePatterns $excludePatterns)
1060 Write-SecurityLog -CIAnnotation "Found $(@($filesToScan).Count) files to scan" -Level Info
1061
1062 # Scan for violations
1063 $allViolations = @()
1064 $totalDependencyCount = 0
1065 foreach ($fileInfo in $filesToScan) {
1066 Write-SecurityLog -CIAnnotation "Scanning: $($fileInfo.RelativePath)" -Level Info
1067 $scanResult = Get-DependencyViolation -FileInfo $fileInfo
1068 $totalDependencyCount += $scanResult.TotalCount
1069 $violations = @($scanResult.Violations)
1070
1071 # Add remediation suggestions
1072 foreach ($violation in $violations) {
1073 $violation.Remediation = Get-RemediationSuggestion -Violation $violation -Remediate:$Remediate
1074 }
1075
1076 $allViolations += $violations
1077 }
1078
1079 Write-SecurityLog -CIAnnotation "Found $(@($allViolations).Count) dependency pinning violations" -Level Info
1080
1081 # Emit per-violation CI annotations and console output
1082 if ($allViolations.Count -gt 0) {
1083 Write-Host "`n❌ Found $($allViolations.Count) unpinned dependencies:" -ForegroundColor Red
1084 $groupedByFile = $allViolations | Group-Object -Property File
1085 foreach ($fileGroup in $groupedByFile) {
1086 Write-Host "`n📄 $($fileGroup.Name)" -ForegroundColor Cyan
1087 foreach ($dep in $fileGroup.Group) {
1088 $annotationLevel = switch ($dep.Severity) {
1089 'High' { 'Error' }
1090 'Medium' { 'Warning' }
1091 default { 'Notice' }
1092 }
1093 $icon = switch ($dep.Severity) {
1094 'High' { '❌' }
1095 'Medium' { '⚠️' }
1096 default { 'ℹ️' }
1097 }
1098 $color = switch ($dep.Severity) {
1099 'High' { 'Red' }
1100 'Medium' { 'Yellow' }
1101 default { 'Cyan' }
1102 }
1103 Write-Host " $icon [$($dep.Severity)] $($dep.Name)@$($dep.Version): $($dep.Description) (Line $($dep.Line))" -ForegroundColor $color
1104 Write-CIAnnotation `
1105 -Message "[$($dep.ViolationType)] $($dep.Name): $($dep.Description)" `
1106 -Level $annotationLevel `
1107 -File $dep.File `
1108 -Line $dep.Line
1109 }
1110 }
1111 }
1112 else {
1113 Write-Host "`n✅ All dependencies are properly pinned." -ForegroundColor Green
1114 }
1115
1116 # Generate compliance report
1117 $report = Get-ComplianceReportData -Violations $allViolations -ScannedFiles $filesToScan -ScanPath $Path -TotalDependencies $totalDependencyCount -Remediate:$Remediate
1118
1119 # Export report
1120 Export-ComplianceReport -Report $report -Format $Format -OutputPath $OutputPath
1121
1122 # Export CI/CD artifacts
1123 Export-CICDArtifact -Report $report -ReportPath $OutputPath
1124
1125 # Display summary
1126 Write-SecurityLog -CIAnnotation "Compliance Analysis Complete!" -Level Success
1127 Write-SecurityLog -CIAnnotation "Compliance Score: $($report.ComplianceScore)%" -Level Info
1128 Write-SecurityLog -CIAnnotation "Total Dependencies: $($report.TotalDependencies)" -Level Info
1129 Write-SecurityLog -CIAnnotation "Unpinned Dependencies: $($report.UnpinnedDependencies)" -Level Info
1130
1131 if ($report.UnpinnedDependencies -gt 0) {
1132 Write-SecurityLog -CIAnnotation "$($report.UnpinnedDependencies) dependencies require pinning for security compliance" -Level Warning
1133
1134 # Check threshold compliance
1135 if ($report.ComplianceScore -lt $Threshold) {
1136 Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% is below threshold $Threshold%" -Level Error
1137
1138 if ($FailOnUnpinned) {
1139 Write-SecurityLog -CIAnnotation "Failing build due to compliance threshold violation (-FailOnUnpinned enabled)" -Level Error
1140 throw "Compliance score $($report.ComplianceScore)% is below threshold $Threshold% (-FailOnUnpinned enabled)"
1141 }
1142 else {
1143 Write-SecurityLog -CIAnnotation "Threshold violation detected but continuing (soft-fail mode)" -Level Warning
1144 }
1145 }
1146 else {
1147 Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% meets threshold $Threshold%" -Level Info
1148 }
1149 }
1150 else {
1151 Write-SecurityLog -CIAnnotation "All dependencies are properly pinned! ✅ (100% compliance, exceeds $Threshold% threshold)" -Level Success
1152 }
1153}
1154
1155#endregion Functions
1156
1157#region Main Execution
1158if ($MyInvocation.InvocationName -ne '.') {
1159 try {
1160 Invoke-DependencyPinningAnalysis `
1161 -Path $Path `
1162 -IncludeTypes $IncludeTypes `
1163 -ExcludePaths $ExcludePaths `
1164 -Format $Format `
1165 -OutputPath $OutputPath `
1166 -FailOnUnpinned:$FailOnUnpinned `
1167 -Threshold $Threshold `
1168 -Remediate:$Remediate
1169 exit 0
1170 }
1171 catch {
1172 Write-Error -ErrorAction Continue "Test-DependencyPinning failed: $($_.Exception.Message)"
1173 Write-CIAnnotation -Message $_.Exception.Message -Level Error
1174 exit 1
1175 }
1176}
1177#endregion Main Execution
1178