#!/usr/bin/env pwsh
# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT
#Requires -Version 7.0
<#
.SYNOPSIS
Verifies and reports on dependency pinning compliance for supply chain security.
.DESCRIPTION
Cross-platform PowerShell script that analyzes GitHub Actions workflows, Docker images,
and other dependency declarations to verify compliance with dependency pinning security practices.
Identifies unpinned dependencies and provides remediation guidance.
.PARAMETER Path
Root path to scan for dependency files. Defaults to current directory.
.PARAMETER Recursive
Scan recursively through subdirectories. Default is true.
.PARAMETER Format
Output format for compliance report. Options: json, sarif, csv, markdown, table.
Default is 'json' for programmatic processing.
.PARAMETER OutputPath
Path where compliance results should be saved. Defaults to 'dependency-pinning-report.json'
in the current directory.
.PARAMETER FailOnUnpinned
Exit with error code if pinning violations are found. Default is false for reporting mode.
.PARAMETER ExcludePaths
Comma-separated list of paths to exclude from scanning (glob patterns supported).
.PARAMETER IncludeTypes
Comma-separated list of dependency types to check. Options: github-actions, npm, pip.
Default is all types.
.PARAMETER Threshold
Minimum compliance score percentage required for passing grade (0-100).
Script will exit with code 1 if compliance falls below threshold when -FailOnUnpinned is set.
Default is 95%.
.PARAMETER Remediate
Generate remediation suggestions with specific SHA pins for unpinned dependencies.
.EXAMPLE
./Test-DependencyPinning.ps1
Scan current directory for dependency pinning compliance.
.EXAMPLE
./Test-DependencyPinning.ps1 -Path "/workspace" -Format "sarif" -FailOnUnpinned
Scan workspace directory, output SARIF format, fail on violations.
.EXAMPLE
./Test-DependencyPinning.ps1 -IncludeTypes "github-actions,pip" -Remediate
Check only GitHub Actions and pip dependencies with remediation suggestions.
.EXAMPLE
./Test-DependencyPinning.ps1 -Threshold 90 -FailOnUnpinned
Enforce 90% compliance threshold and fail build if not met.
.EXAMPLE
./Test-DependencyPinning.ps1 -Threshold 100 -IncludeTypes "github-actions"
Require 100% SHA pinning for GitHub Actions only.
.EXAMPLE
./Test-DependencyPinning.ps1 -Threshold 80
Report compliance against 80% threshold but continue on violations.
.NOTES
Requires:
- PowerShell 7.0 or later for cross-platform compatibility
- Internet connectivity for SHA resolution (with -Remediate)
- GitHub API access for action SHA resolution (optional)
Compatible with:
- Windows PowerShell 5.1+ (limited cross-platform features)
- PowerShell 7.x on Windows, Linux, macOS
- GitHub Actions runners (ubuntu-latest, windows-latest, macos-latest)
- Azure DevOps agents (Microsoft-hosted and self-hosted)
.LINK
https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions
#>
# Import security classes from shared module
using module ./Modules/SecurityClasses.psm1
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$Path = ".",
[Parameter(Mandatory = $false)]
[ValidateSet('json', 'sarif', 'csv', 'markdown', 'table')]
[string]$Format = 'json',
[Parameter(Mandatory = $false)]
[string]$OutputPath = 'logs/dependency-pinning-results.json',
[Parameter(Mandatory = $false)]
[switch]$FailOnUnpinned,
[Parameter(Mandatory = $false)]
[string]$ExcludePaths = "",
[Parameter(Mandatory = $false)]
[string]$IncludeTypes = "github-actions,npm,pip,shell-downloads,workflow-npm-commands",
[Parameter(Mandatory = $false)]
[ValidateRange(0, 100)]
[int]$Threshold = 95,
[Parameter(Mandatory = $false)]
[switch]$Remediate
)
$ErrorActionPreference = 'Stop'
# Import CIHelpers for workflow command escaping
Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force
$script:GitHubApiBase = Get-GitHubApiBase
# Define dependency patterns for different ecosystems
$DependencyPatterns = @{
'github-actions' = @{
FilePatterns = @('**/.github/workflows/*.yml', '**/.github/workflows/*.yaml')
VersionPatterns = @(
@{
Pattern = 'uses:\s*([^@\s]+)@([^#\s]+)'
Groups = @{ Action = 1; Version = 2 }
Description = 'GitHub Actions uses statements'
}
)
SHAPattern = '^[a-fA-F0-9]{40}$'
RemediationUrl = "$script:GitHubApiBase/repos/{0}/commits/{1}"
}
'npm' = @{
FilePatterns = @('**/package.json')
ExcludePatterns = @('node_modules')
ValidationFunc = 'Get-NpmDependencyViolations'
RemediationUrl = 'https://registry.npmjs.org/{0}/{1}'
}
'pip' = @{
FilePatterns = @('**/requirements*.txt', '**/Pipfile', '**/pyproject.toml', '**/setup.py')
ExcludePatterns = @('.venv', 'venv', '.tox', '.nox', '__pypackages__')
VersionPatterns = @(
@{
Pattern = '([a-zA-Z0-9\-_]+)==([^#\s]+)'
Groups = @{ Package = 1; Version = 2 }
Description = 'Python pip requirements'
}
)
SHAPattern = '^[a-fA-F0-9]{40}$'
RemediationUrl = 'https://pypi.org/pypi/{0}/{1}/json'
}
'shell-downloads' = @{
FilePatterns = @('**/.devcontainer/scripts/*.sh', '**/scripts/*.sh')
ExcludePatterns = @('Fixtures')
ValidationFunc = 'Test-ShellDownloadSecurity'
Description = 'Shell script downloads must include checksum verification'
}
'workflow-npm-commands' = @{
FilePatterns = @('**/.github/workflows/*.yml', '**/.github/workflows/*.yaml')
ValidationFunc = 'Get-WorkflowNpmCommandViolations'
Description = 'Workflow npm install/update commands should use npm ci'
}
}
# DependencyViolation and ComplianceReport classes moved to ./Modules/SecurityClasses.psm1
#region Functions
function Test-NpmCommandLine {
<#
.SYNOPSIS
Tests whether a line contains an unpinned npm command.
.DESCRIPTION
Matches npm install, npm i, npm update, and npm install-test commands.
Does not match npm ci, npm run, npm test, npm audit, or npx.
.PARAMETER Line
The text line to test for npm commands.
.OUTPUTS
System.String or $null
#>
param(
[Parameter(Mandatory)]
[string]$Line
)
if ($Line -match '\bnpm\s+(install-test|install|update)\b') {
return $Matches[0]
}
if ($Line -match '\bnpm\s+i\b(?!nstall|nit)') {
return $Matches[0]
}
return $null
}
function New-NpmCommandViolation {
<#
.SYNOPSIS
Creates a DependencyViolation for an unpinned npm command.
.DESCRIPTION
Constructs a DependencyViolation object with standard fields for
npm command violations detected in workflow run: steps.
.PARAMETER FileInfo
Hashtable with Path, Type, and RelativePath keys.
.PARAMETER LineNumber
1-based line number of the violation.
.PARAMETER Line
The source line containing the npm command.
.PARAMETER Command
The matched npm command string.
.OUTPUTS
DependencyViolation
#>
param(
[Parameter(Mandatory)]
[hashtable]$FileInfo,
[Parameter(Mandatory)]
[int]$LineNumber,
[Parameter(Mandatory)]
[string]$Line,
[Parameter(Mandatory)]
[string]$Command
)
$violation = [DependencyViolation]::new(
$FileInfo.RelativePath,
$LineNumber,
'workflow-npm-commands',
$Command,
'Medium',
"Unpinned npm command detected: '$Command'. Use 'npm ci' for deterministic installs from lockfile."
)
$violation.ViolationType = 'Unpinned'
$violation.CurrentRef = $Line.Trim()
$violation.Remediation = "Replace '$Command' with 'npm ci' for reproducible builds."
return $violation
}
function Get-WorkflowNpmCommandViolations {
<#
.SYNOPSIS
Detects unpinned npm install commands in GitHub Actions workflow run: steps.
.DESCRIPTION
Scans workflow YAML files for run: blocks and detects npm commands that
modify the dependency tree (install, i, update, install-test). Commands
that use the lockfile deterministically (ci) or do not install packages
(run, test, audit) are not flagged.
Uses indentation-aware parsing to confine detection to actual run: block
content, reducing false positives from YAML comments or unrelated keys.
.PARAMETER FileInfo
Hashtable with Path, Type, and RelativePath keys identifying the file to scan.
.OUTPUTS
DependencyViolation[]
#>
param(
[Parameter(Mandatory)]
[hashtable]$FileInfo
)
$violations = @()
$totalNpmCommands = 0
$filePath = $FileInfo.Path
if (-not (Test-Path -LiteralPath $filePath)) {
return @{ TotalCount = 0; Violations = @() }
}
$lines = Get-Content -LiteralPath $filePath
$inRunBlock = $false
$runBlockIndent = 0
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
$trimmed = $line.TrimStart()
if ($trimmed -eq '' -or $trimmed.StartsWith('#')) {
continue
}
$currentIndent = $line.Length - $line.TrimStart().Length
if ($trimmed -match '^run:\s*(.*)$') {
$runContent = $Matches[1].Trim()
$runBlockIndent = $currentIndent
if ($runContent -and $runContent -notmatch '^[|>]') {
$npmMatch = Test-NpmCommandLine -Line $runContent
if ($npmMatch) {
$totalNpmCommands++
$violations += New-NpmCommandViolation -FileInfo $FileInfo -LineNumber ($i + 1) -Line $runContent -Command $npmMatch
}
$inRunBlock = $false
} else {
$inRunBlock = $true
}
continue
}
if ($inRunBlock) {
if ($currentIndent -le $runBlockIndent) {
$inRunBlock = $false
if ($trimmed -match '^run:\s*(.*)$') {
$i--
continue
}
} else {
if ($trimmed.StartsWith('#')) {
continue
}
$npmMatch = Test-NpmCommandLine -Line $trimmed
if ($npmMatch) {
$totalNpmCommands++
$violations += New-NpmCommandViolation -FileInfo $FileInfo -LineNumber ($i + 1) -Line $trimmed -Command $npmMatch
}
}
}
}
return @{ TotalCount = $totalNpmCommands; Violations = $violations }
}
function Test-ShellDownloadSecurity {
<#
.SYNOPSIS
Scans shell scripts for curl/wget downloads lacking checksum verification.
.DESCRIPTION
Analyzes shell scripts to detect download commands (curl/wget) that do not
have corresponding checksum verification (sha256sum/shasum) within the
following lines.
.PARAMETER FileInfo
Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[hashtable]$FileInfo
)
$FilePath = $FileInfo.Path
if (-not (Test-Path $FilePath)) {
return @{ TotalCount = 0; Violations = @() }
}
$lines = Get-Content $FilePath
$violations = @()
$totalDownloads = 0
# Pattern to match curl/wget download commands
$downloadPattern = '(curl|wget)\s+.*https?://[^\s]+'
$checksumPattern = 'sha256sum|shasum|Get-FileHash|openssl\s+dgst\s+-sha256|sha256sum\s+-c'
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($line -match $downloadPattern) {
$totalDownloads++
# Check next 5 lines for checksum verification
$hasChecksum = $false
$searchEnd = [Math]::Min($i + 5, $lines.Count - 1)
for ($j = $i; $j -le $searchEnd; $j++) {
if ($lines[$j] -match $checksumPattern) {
$hasChecksum = $true
break
}
}
if (-not $hasChecksum) {
$violation = [DependencyViolation]::new()
$violation.File = $FileInfo.RelativePath
$violation.Line = $i + 1
$violation.Type = $FileInfo.Type
$violation.Name = $line.Trim()
$violation.Severity = 'Medium'
$violation.ViolationType = 'Unpinned'
$violation.Description = 'Download without checksum verification'
$violation.Metadata = @{ Pattern = $line.Trim() }
$violations += $violation
}
}
}
return @{ TotalCount = $totalDownloads; Violations = $violations }
}
function Get-NpmDependencyViolations {
<#
.SYNOPSIS
Analyzes package.json files for unpinned npm dependencies.
.DESCRIPTION
Parses package.json as JSON and checks dependency sections
(dependencies, devDependencies, peerDependencies, optionalDependencies)
for exact version pinning. Versions must be exact semver (e.g. 1.2.3)
without range operators like ^, ~, *, >=, ||, or URL/git references.
.PARAMETER FileInfo
Hashtable with Path, Type, and RelativePath keys from Get-FilesToScan.
.OUTPUTS
Array of PSCustomObjects representing dependency violations.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[hashtable]$FileInfo
)
$filePath = $FileInfo.Path
$relativePath = $FileInfo.RelativePath
$type = $FileInfo.Type
$violations = @()
$totalCount = 0
if (-not (Test-Path -Path $filePath -PathType Leaf)) {
return @{ TotalCount = 0; Violations = @() }
}
try {
$content = Get-Content -Path $filePath -Raw -ErrorAction Stop
$packageJson = $content | ConvertFrom-Json -ErrorAction Stop
}
catch {
Write-Warning "Failed to parse $relativePath as JSON: $_"
return @{ TotalCount = 0; Violations = @() }
}
# Build a line-number lookup from raw file content
$lines = Get-Content -Path $filePath -ErrorAction SilentlyContinue
$dependencySections = @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies')
foreach ($section in $dependencySections) {
$deps = $packageJson.$section
if ($null -eq $deps) {
continue
}
foreach ($prop in $deps.PSObject.Properties) {
$packageName = $prop.Name
$version = $prop.Value
if ([string]::IsNullOrWhiteSpace($version)) {
continue
}
$totalCount++
$isPinned = Test-NpmExactVersion -Version $version
if (-not $isPinned) {
# Find the line number by searching for the package name in the file
$lineNumber = 1
if ($null -ne $lines) {
$escapedName = [regex]::Escape($packageName)
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match """$escapedName""\s*:") {
$lineNumber = $i + 1
break
}
}
}
$violation = [DependencyViolation]::new()
$violation.File = $relativePath
$violation.Line = $lineNumber
$violation.Type = $type
$violation.Name = $packageName
$violation.Version = $version
$violation.Severity = 'Medium'
$violation.ViolationType = 'Unpinned'
$violation.Description = "Unpinned npm dependency in $section"
$violation.Metadata = @{ Section = $section }
$violations += $violation
}
}
}
return @{ TotalCount = $totalCount; Violations = $violations }
}
function Test-NpmExactVersion {
<#
.SYNOPSIS
Tests whether an npm version string is an exact pinned version.
.DESCRIPTION
Returns $true for exact semver versions (e.g. 1.2.3, 1.0.0-beta.1).
Returns $false for ranges, wildcards, URLs, tags, and git references.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Version
)
# Reject range operators, wildcards, URLs, git refs, and tags like "latest"
if ($Version -match '^[~^>=<*|]' -or
$Version -match '://' -or
$Version -match '\.git\b' -or
$Version -match '\s*\|\|' -or
$Version -match '^\w+$' -and $Version -notmatch '^\d') {
return $false
}
# Accept exact semver: major.minor.patch with optional prerelease/build metadata
return $Version -match '^\d+\.\d+\.\d+(-[a-zA-Z0-9._-]+)?(\+[a-zA-Z0-9._-]+)?$'
}
function Get-FilesToScan {
<#
.SYNOPSIS
Discovers files to scan based on dependency type patterns.
#>
[CmdletBinding()]
param(
[string]$ScanPath,
[string[]]$Types,
[string[]]$ExcludePatterns
)
$allFiles = @()
foreach ($type in $Types) {
if ($DependencyPatterns.ContainsKey($type)) {
$patterns = $DependencyPatterns[$type].FilePatterns
foreach ($pattern in $patterns) {
try {
# Decompose glob into a directory prefix and a leaf filename filter.
# Get-ChildItem -Path does not expand ** globs on all platforms,
# so we strip the ** segments and use -Recurse with -Filter instead.
$segments = $pattern -split '[/\\]'
$leafFilter = $segments[-1]
$dirSegments = $segments[0..($segments.Length - 2)] | Where-Object { $_ -ne '**' }
if ($dirSegments.Count -gt 0) {
$basePath = Join-Path $ScanPath ($dirSegments -join [System.IO.Path]::DirectorySeparatorChar)
}
else {
$basePath = $ScanPath
}
if (-not (Test-Path -Path $basePath -PathType Container)) {
continue
}
$files = Get-ChildItem -Path $basePath -Filter $leafFilter -Recurse -File -ErrorAction SilentlyContinue
# Merge type-specific exclude patterns with caller-provided patterns
$mergedExcludes = @()
if ($ExcludePatterns) {
$mergedExcludes += @($ExcludePatterns)
}
if ($DependencyPatterns[$type].ContainsKey('ExcludePatterns')) {
$mergedExcludes += $DependencyPatterns[$type].ExcludePatterns
}
if ($mergedExcludes) {
foreach ($exclude in $mergedExcludes) {
$files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
}
}
$allFiles += $files | ForEach-Object {
@{
Path = $_.FullName
Type = $type
RelativePath = [System.IO.Path]::GetRelativePath($ScanPath, $_.FullName)
}
}
}
catch {
Write-SecurityLog -CIAnnotation "Error scanning for $type files with pattern $pattern`: $($_.Exception.Message)" -Level Warning
}
}
}
}
return $allFiles | Sort-Object Path -Unique
}
function Test-SHAPinning {
<#
.SYNOPSIS
Tests if a version reference is properly SHA-pinned.
#>
[CmdletBinding()]
param(
[string]$Version,
[string]$Type
)
if ($DependencyPatterns.ContainsKey($Type) -and $DependencyPatterns[$Type].SHAPattern) {
$shaPattern = $DependencyPatterns[$Type].SHAPattern
return $Version -match $shaPattern
}
return $false
}
function Get-DependencyViolation {
<#
.SYNOPSIS
Scans a file for dependency pinning violations.
#>
[CmdletBinding()]
param(
[hashtable]$FileInfo
)
$violations = @()
$filePath = $FileInfo.Path
$fileType = $FileInfo.Type
if (!(Test-Path $filePath)) {
return @{ TotalCount = 0; Violations = @() }
}
# Check if this type uses a validation function instead of regex patterns
if ($null -ne $DependencyPatterns[$fileType].ValidationFunc) {
$funcName = $DependencyPatterns[$fileType].ValidationFunc
$scanResult = & $funcName -FileInfo $FileInfo
if ($null -eq $scanResult) {
return @{ TotalCount = 0; Violations = @() }
}
foreach ($v in @($scanResult.Violations)) {
if ($null -eq $v) {
continue
}
if (-not ($v -is [DependencyViolation])) {
$actualType = $v.GetType().FullName
throw "Validation function '$funcName' must return [DependencyViolation] objects, got '$actualType'."
}
if (-not $v.File) {
$v.File = $FileInfo.RelativePath
}
if ($v.Line -lt 1) {
$v.Line = 1
}
if (-not $v.Type) {
$v.Type = $fileType
}
}
return $scanResult
}
try {
$content = Get-Content -Path $filePath -Raw
$lines = Get-Content -Path $filePath
$patterns = $DependencyPatterns[$fileType].VersionPatterns
$totalCount = 0
foreach ($patternInfo in $patterns) {
$pattern = $patternInfo.Pattern
$description = $patternInfo.Description
$regexMatches = [regex]::Matches($content, $pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
$totalCount += @($regexMatches).Count
foreach ($match in $regexMatches) {
# Find line number
$lineNumber = 1
$position = $match.Index
for ($i = 0; $i -lt $position; $i++) {
if ($content[$i] -eq "`n") {
$lineNumber++
}
}
# Extract dependency information
$dependencyName = $match.Groups[1].Value
$version = $match.Groups[2].Value
# Check if properly pinned
if (!(Test-SHAPinning -Version $version -Type $fileType)) {
$violation = [DependencyViolation]::new()
$violation.File = $FileInfo.RelativePath
$violation.Line = $lineNumber
$violation.Type = $fileType
$violation.Name = $dependencyName
$violation.Version = $version
$violation.CurrentRef = $match.Value
$violation.Description = "Unpinned dependency: $description"
$violation.Severity = if ($fileType -eq 'github-actions') { 'High' } else { 'Medium' }
$violation.ViolationType = 'Unpinned'
$violation.Metadata['PatternDescription'] = $description
$violation.Metadata['LineContent'] = $lines[$lineNumber - 1]
$violations += $violation
}
}
}
}
catch {
Write-SecurityLog -CIAnnotation "Error scanning file $filePath`: $($_.Exception.Message)" -Level Warning
}
return @{ TotalCount = $totalCount; Violations = $violations }
}
function Get-RemediationSuggestion {
<#
.SYNOPSIS
Generates remediation suggestions for unpinned dependencies.
#>
[CmdletBinding()]
param(
[DependencyViolation]$Violation,
[switch]$Remediate
)
$type = $Violation.Type
$name = $Violation.Name
$version = $Violation.Version
if (!$Remediate) {
return "Enable -Remediate flag for specific SHA suggestions"
}
try {
switch ($type) {
'github-actions' {
# For GitHub Actions, resolve tag to commit SHA
$apiUrl = "$script:GitHubApiBase/repos/$name/commits/$version"
$headers = @{}
if ($env:GITHUB_TOKEN) {
$headers['Authorization'] = "Bearer $env:GITHUB_TOKEN"
}
$response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -TimeoutSec 30
$sha = $response.sha
if ($sha) {
return "Pin to SHA: uses: $name@$sha # $version"
}
}
default {
return "Research and pin to specific commit SHA or content hash for $type dependencies"
}
}
}
catch {
Write-SecurityLog -CIAnnotation "Could not generate automatic remediation for $($Violation.Name): $($_.Exception.Message)" -Level Warning
}
return "Manually research and pin to immutable reference"
}
function Get-ComplianceReportData {
<#
.SYNOPSIS
Generates a comprehensive compliance report.
#>
[CmdletBinding()]
param(
[DependencyViolation[]]$Violations,
[hashtable[]]$ScannedFiles,
[string]$ScanPath,
[Parameter(Mandatory)]
[int]$TotalDependencies,
[switch]$Remediate
)
$report = [ComplianceReport]::new()
$report.ScanPath = $ScanPath
$report.ScannedFiles = $ScannedFiles.Count
$report.Violations = $Violations
# Calculate metrics using true dependency counts from scanners
$report.TotalDependencies = $TotalDependencies
$report.UnpinnedDependencies = @($Violations).Count
$report.PinnedDependencies = $TotalDependencies - $report.UnpinnedDependencies
$report.CalculateScore()
# Generate summary by type
$report.Summary = @{}
foreach ($type in @($Violations | Group-Object Type)) {
$report.Summary[$type.Name] = @{
Total = $type.Count
High = @($type.Group | Where-Object { $_.Severity -eq 'High' }).Count
Medium = @($type.Group | Where-Object { $_.Severity -eq 'Medium' }).Count
Low = @($type.Group | Where-Object { $_.Severity -eq 'Low' }).Count
}
}
# Add metadata
$report.Metadata = @{
PowerShellVersion = $PSVersionTable.PSVersion.ToString()
Platform = $PSVersionTable.Platform
ScanTimestamp = $report.Timestamp.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
IncludedTypes = $IncludeTypes
ExcludedPaths = $ExcludePaths
RemediationEnabled = $Remediate.IsPresent
ComplianceThreshold = $Threshold
}
return $report
}
function Export-ComplianceReport {
<#
.SYNOPSIS
Exports compliance report in specified format.
#>
[CmdletBinding()]
param(
# Use duck typing to avoid class type collision during code coverage instrumentation
$Report,
[string]$Format,
[string]$OutputPath
)
# Validate required properties on duck-typed $Report parameter (ComplianceReport schema)
$requiredProperties = @('ComplianceScore', 'Violations', 'TotalDependencies', 'UnpinnedDependencies', 'Metadata')
foreach ($prop in $requiredProperties) {
if ($null -eq $Report.PSObject.Properties[$prop]) {
throw "Report object missing required property: $prop"
}
}
# Ensure parent directory exists
$parentDir = Split-Path -Path $OutputPath -Parent
if ($parentDir -and -not (Test-Path $parentDir)) {
New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
}
switch ($Format.ToLower()) {
'json' {
$Report | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8
}
'sarif' {
$sarif = @{
version = "2.1.0"
"`$schema" = "https://json.schemastore.org/sarif-2.1.0.json"
runs = @(@{
tool = @{
driver = @{
name = "dependency-pinning-analyzer"
version = "1.0.0"
informationUri = "https://github.com/microsoft/hve-core"
}
}
results = @($Report.Violations | ForEach-Object {
@{
ruleId = "dependency-not-pinned"
level = switch ($_.Severity) { 'High' { 'error' } 'Medium' { 'warning' } default { 'note' } }
message = @{ text = $_.Description }
locations = @(@{
physicalLocation = @{
artifactLocation = @{ uri = $_.File }
region = @{ startLine = $_.Line }
}
})
properties = @{
dependencyName = $_.Name
currentVersion = $_.Version
remediation = $_.Remediation
}
}
})
})
}
$sarif | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding UTF8
}
'csv' {
$Report.Violations | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
'markdown' {
$markdown = @"
# Dependency Pinning Compliance Report
**Scan Date:** $($Report.Timestamp.ToString('yyyy-MM-dd HH:mm:ss'))
**Scan Path:** $($Report.ScanPath)
**Compliance Score:** $($Report.ComplianceScore)%
## Summary
| Metric | Count |
|--------|--------|
| Total Files Scanned | $($Report.ScannedFiles) |
| Total Dependencies | $($Report.TotalDependencies) |
| Pinned Dependencies | $($Report.PinnedDependencies) |
| Unpinned Dependencies | $($Report.UnpinnedDependencies) |
## Violations by Type
"@
foreach ($type in $Report.Summary.Keys) {
$summary = $Report.Summary[$type]
$markdown += @"
### $type
- **Total:** $($summary.Total)
- **High Severity:** $($summary.High)
- **Medium Severity:** $($summary.Medium)
- **Low Severity:** $($summary.Low)
"@
}
if ($Report.Violations.Count -gt 0) {
$markdown += @"
## Detailed Violations
| File | Line | Type | Dependency | Current Version | Severity | Remediation |
|------|------|------|------------|----------------|----------|-------------|
"@
foreach ($violation in $Report.Violations) {
$markdown += "|$($violation.File)|$($violation.Line)|$($violation.Type)|$($violation.Name)|$($violation.Version)|$($violation.Severity)|$($violation.Remediation)|`n"
}
}
$markdown | Out-File -FilePath $OutputPath -Encoding UTF8
}
'table' {
# Display formatted table to console and save simple text format
if ($Report.Violations.Count -gt 0) {
$Report.Violations | Format-Table -Property File, Line, Type, Name, Version, Severity -AutoSize | Out-File -FilePath $OutputPath -Encoding UTF8 -Width 200
}
else {
"No dependency pinning violations found." | Out-File -FilePath $OutputPath -Encoding UTF8
}
}
}
Write-SecurityLog -CIAnnotation "Compliance report exported to: $OutputPath" -Level Success
}
function Export-CICDArtifact {
<#
.SYNOPSIS
Exports compliance report as CI/CD artifacts for both GitHub Actions and Azure DevOps.
#>
[CmdletBinding()]
param(
[ComplianceReport]$Report,
[string]$ReportPath
)
Write-SecurityLog -CIAnnotation "Preparing compliance artifacts for CI/CD systems..." -Level Info
$platform = Get-CIPlatform
Write-SecurityLog -CIAnnotation "Detected $platform environment - setting up artifacts" -Level Info
# Set CI outputs (works for both GitHub Actions and Azure DevOps)
Set-CIOutput -Name 'dependency-report' -Value $ReportPath -IsOutput
Set-CIOutput -Name 'compliance-score' -Value $Report.ComplianceScore -IsOutput
Set-CIOutput -Name 'unpinned-count' -Value $Report.UnpinnedDependencies -IsOutput
# Create summary content
$summaryContent = @"
# 📌 Dependency Pinning Analysis
**Compliance Score:** $($Report.ComplianceScore)%
**Unpinned Dependencies:** $($Report.UnpinnedDependencies)
**Total Dependencies Scanned:** $($Report.TotalDependencies)
$(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!" })
"@
# Write step summary
Write-CIStepSummary -Content $summaryContent
# Publish artifact
Publish-CIArtifact -Path $ReportPath -Name 'dependency-pinning-report' -ContainerFolder 'dependency-pinning'
# Set up local artifact directory for GitHub Actions upload-artifact action
if ($platform -eq 'github') {
$artifactDir = Join-Path $PWD "dependency-pinning-artifacts"
New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null
Copy-Item -Path $ReportPath -Destination $artifactDir -Force
}
Write-SecurityLog -CIAnnotation "Compliance artifacts prepared for CI/CD consumption" -Level Success
}
function Invoke-DependencyPinningAnalysis {
<#
.SYNOPSIS
Orchestrates dependency pinning compliance analysis.
#>
[CmdletBinding()]
[OutputType([void])]
param(
[Parameter()]
[string]$Path = ".",
[Parameter()]
[string]$IncludeTypes = "github-actions,npm,pip,shell-downloads,workflow-npm-commands",
[Parameter()]
[string]$ExcludePaths = "",
[Parameter()]
[string]$Format = 'json',
[Parameter()]
[string]$OutputPath = 'logs/dependency-pinning-results.json',
[Parameter()]
[switch]$FailOnUnpinned,
[Parameter()]
[int]$Threshold = 95,
[Parameter()]
[switch]$Remediate
)
Write-SecurityLog -CIAnnotation "Starting dependency pinning compliance analysis..." -Level Info
Write-SecurityLog -CIAnnotation "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info
Write-SecurityLog -CIAnnotation "Platform: $($PSVersionTable.Platform)" -Level Info
# Parse include types and exclude paths
$typesToCheck = $IncludeTypes.Split(',') | ForEach-Object { $_.Trim() }
$excludePatterns = if ($ExcludePaths) { $ExcludePaths.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
Write-SecurityLog -CIAnnotation "Scanning path: $Path" -Level Info
Write-SecurityLog -CIAnnotation "Include types: $($typesToCheck -join ', ')" -Level Info
if ($excludePatterns) { Write-SecurityLog -CIAnnotation "Exclude patterns: $($excludePatterns -join ', ')" -Level Info }
# Discover files to scan
$filesToScan = @(Get-FilesToScan -ScanPath $Path -Types $typesToCheck -ExcludePatterns $excludePatterns)
Write-SecurityLog -CIAnnotation "Found $(@($filesToScan).Count) files to scan" -Level Info
# Scan for violations
$allViolations = @()
$totalDependencyCount = 0
foreach ($fileInfo in $filesToScan) {
Write-SecurityLog -CIAnnotation "Scanning: $($fileInfo.RelativePath)" -Level Info
$scanResult = Get-DependencyViolation -FileInfo $fileInfo
$totalDependencyCount += $scanResult.TotalCount
$violations = @($scanResult.Violations)
# Add remediation suggestions
foreach ($violation in $violations) {
$violation.Remediation = Get-RemediationSuggestion -Violation $violation -Remediate:$Remediate
}
$allViolations += $violations
}
Write-SecurityLog -CIAnnotation "Found $(@($allViolations).Count) dependency pinning violations" -Level Info
# Emit per-violation CI annotations and console output
if ($allViolations.Count -gt 0) {
Write-Host "`n❌ Found $($allViolations.Count) unpinned dependencies:" -ForegroundColor Red
$groupedByFile = $allViolations | Group-Object -Property File
foreach ($fileGroup in $groupedByFile) {
Write-Host "`n📄 $($fileGroup.Name)" -ForegroundColor Cyan
foreach ($dep in $fileGroup.Group) {
$annotationLevel = switch ($dep.Severity) {
'High' { 'Error' }
'Medium' { 'Warning' }
default { 'Notice' }
}
$icon = switch ($dep.Severity) {
'High' { '❌' }
'Medium' { '⚠️' }
default { 'ℹ️' }
}
$color = switch ($dep.Severity) {
'High' { 'Red' }
'Medium' { 'Yellow' }
default { 'Cyan' }
}
Write-Host " $icon [$($dep.Severity)] $($dep.Name)@$($dep.Version): $($dep.Description) (Line $($dep.Line))" -ForegroundColor $color
Write-CIAnnotation `
-Message "[$($dep.ViolationType)] $($dep.Name): $($dep.Description)" `
-Level $annotationLevel `
-File $dep.File `
-Line $dep.Line
}
}
}
else {
Write-Host "`n✅ All dependencies are properly pinned." -ForegroundColor Green
}
# Generate compliance report
$report = Get-ComplianceReportData -Violations $allViolations -ScannedFiles $filesToScan -ScanPath $Path -TotalDependencies $totalDependencyCount -Remediate:$Remediate
# Export report
Export-ComplianceReport -Report $report -Format $Format -OutputPath $OutputPath
# Export CI/CD artifacts
Export-CICDArtifact -Report $report -ReportPath $OutputPath
# Display summary
Write-SecurityLog -CIAnnotation "Compliance Analysis Complete!" -Level Success
Write-SecurityLog -CIAnnotation "Compliance Score: $($report.ComplianceScore)%" -Level Info
Write-SecurityLog -CIAnnotation "Total Dependencies: $($report.TotalDependencies)" -Level Info
Write-SecurityLog -CIAnnotation "Unpinned Dependencies: $($report.UnpinnedDependencies)" -Level Info
if ($report.UnpinnedDependencies -gt 0) {
Write-SecurityLog -CIAnnotation "$($report.UnpinnedDependencies) dependencies require pinning for security compliance" -Level Warning
# Check threshold compliance
if ($report.ComplianceScore -lt $Threshold) {
Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% is below threshold $Threshold%" -Level Error
if ($FailOnUnpinned) {
Write-SecurityLog -CIAnnotation "Failing build due to compliance threshold violation (-FailOnUnpinned enabled)" -Level Error
throw "Compliance score $($report.ComplianceScore)% is below threshold $Threshold% (-FailOnUnpinned enabled)"
}
else {
Write-SecurityLog -CIAnnotation "Threshold violation detected but continuing (soft-fail mode)" -Level Warning
}
}
else {
Write-SecurityLog -CIAnnotation "Compliance score $($report.ComplianceScore)% meets threshold $Threshold%" -Level Info
}
}
else {
Write-SecurityLog -CIAnnotation "All dependencies are properly pinned! ✅ (100% compliance, exceeds $Threshold% threshold)" -Level Success
}
}
#endregion Functions
#region Main Execution
if ($MyInvocation.InvocationName -ne '.') {
try {
Invoke-DependencyPinningAnalysis `
-Path $Path `
-IncludeTypes $IncludeTypes `
-ExcludePaths $ExcludePaths `
-Format $Format `
-OutputPath $OutputPath `
-FailOnUnpinned:$FailOnUnpinned `
-Threshold $Threshold `
-Remediate:$Remediate
exit 0
}
catch {
Write-Error -ErrorAction Continue "Test-DependencyPinning failed: $($_.Exception.Message)"
Write-CIAnnotation -Message $_.Exception.Message -Level Error
exit 1
}
}
#endregion Main Executionmicrosoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Test-DependencyPinning.ps1
1168lines · modepreview