microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
ci/884-codeql-python-analysis

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-DependencyPinning.ps1

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