microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
940773c7065ca3c06217d8ebda68b40571cd73f3

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-DependencyPinning.ps1

1168lines · modecode

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