microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/devcontainer-python-uv-887

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-DependencyPinning.ps1

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