microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/context-working

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-PSModulePins.ps1

179lines · modecode

1#!/usr/bin/env pwsh
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4#
5# Test-PSModulePins.ps1
6#
7# Purpose: Enforce that all PowerShell module version pins across the repository
8# match the canonical versions declared in scripts/security/ps-module-versions.json.
9# Author: HVE Core Team
10
11#Requires -Version 7.0
12
13<#
14.SYNOPSIS
15 Validates PowerShell module version pins against the canonical pin config.
16
17.DESCRIPTION
18 Scans tracked repository files for module pins of the form:
19 Install-Module -Name <Module> -RequiredVersion <version>
20 Import-Module -Name <Module> -RequiredVersion <version>
21 #Requires -Modules @{ ModuleName='<Module>'; RequiredVersion='<version>' }
22
23 For each managed module in scripts/security/ps-module-versions.json, every
24 pinned version found in tracked files must match the canonical version.
25
26 Writes JSON results to logs/ps-module-pins-results.json. Exits non-zero on
27 violations.
28
29.PARAMETER ConfigPath
30 Path to the canonical pin config. Defaults to scripts/security/ps-module-versions.json.
31#>
32[CmdletBinding()]
33param(
34 [Parameter(Mandatory = $false)]
35 [string]$ConfigPath
36)
37
38$ErrorActionPreference = 'Stop'
39
40#region Main Function
41function Invoke-PSModulePinScan {
42 [CmdletBinding()]
43 [OutputType([int])]
44 param(
45 [Parameter(Mandatory = $false)]
46 [string]$ConfigPath
47 )
48
49 $repoRoot = git rev-parse --show-toplevel 2>$null
50 if (-not $repoRoot) {
51 $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
52 }
53
54 if (-not $ConfigPath) {
55 $ConfigPath = Join-Path $repoRoot 'scripts/security/ps-module-versions.json'
56 }
57 if (-not (Test-Path $ConfigPath)) {
58 throw "Pin config not found: $ConfigPath"
59 }
60
61 $pinConfig = Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json
62 $canonical = @{}
63 foreach ($prop in $pinConfig.modules.PSObject.Properties) {
64 $canonical[$prop.Name] = $prop.Value.version
65 }
66
67 # Files containing intentional non-canonical version literals (test fixtures, the
68 # config itself, this validator). Paths are relative to repo root and use forward
69 # slashes.
70 $allowedFiles = @(
71 'scripts/security/ps-module-versions.json',
72 'scripts/security/Test-PSModulePins.ps1',
73 'scripts/tests/security/Test-PSModulePins.Tests.ps1',
74 'scripts/tests/security/Test-SHAStaleness.Tests.ps1'
75 )
76
77 $logsDir = Join-Path $repoRoot 'logs'
78 if (-not (Test-Path $logsDir)) {
79 New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
80 }
81 $resultsPath = Join-Path $logsDir 'ps-module-pins-results.json'
82
83 # Build a single regex alternation of managed module names (escaped).
84 $moduleAlt = ($canonical.Keys | ForEach-Object { [regex]::Escape($_) }) -join '|'
85
86 # Patterns:
87 # 1. Install/Import/Update-Module -Name <Mod> ... -RequiredVersion <ver>
88 # 2. #Requires-style hashtable: ModuleName='<Mod>' ... RequiredVersion='<ver>'
89 $patterns = @(
90 "(?<verb>Install-Module|Import-Module|Update-Module)\s+(?:-Name\s+)?['""]?(?<module>$moduleAlt)['""]?[^\r\n#]*?-RequiredVersion\s+['""]?(?<version>\d+\.\d+\.\d+)['""]?",
91 "ModuleName\s*=\s*['""](?<module>$moduleAlt)['""]\s*;\s*RequiredVersion\s*=\s*['""](?<version>\d+\.\d+\.\d+)['""]"
92 )
93
94 # Enumerate tracked files only (avoid logs/, node_modules/, .git/, build outputs).
95 Push-Location $repoRoot
96 try {
97 $trackedFiles = git ls-files | Where-Object {
98 $_ -match '\.(ps1|psm1|psd1|yml|yaml|sh|md)$'
99 }
100 } finally {
101 Pop-Location
102 }
103
104 $violations = [System.Collections.Generic.List[object]]::new()
105 $matchesFound = 0
106
107 foreach ($relPath in $trackedFiles) {
108 if ($allowedFiles -contains $relPath) { continue }
109
110 $full = Join-Path $repoRoot $relPath
111 if (-not (Test-Path -LiteralPath $full)) { continue }
112
113 $lines = @(Get-Content -LiteralPath $full)
114 for ($i = 0; $i -lt $lines.Count; $i++) {
115 $line = $lines[$i]
116 foreach ($pattern in $patterns) {
117 $rxMatches = [regex]::Matches($line, $pattern)
118 foreach ($m in $rxMatches) {
119 $matchesFound++
120 $module = $m.Groups['module'].Value
121 $version = $m.Groups['version'].Value
122 $expected = $canonical[$module]
123 if ($version -ne $expected) {
124 $violations.Add([pscustomobject]@{
125 file = $relPath
126 line = $i + 1
127 module = $module
128 found = $version
129 expected = $expected
130 snippet = $line.Trim()
131 }) | Out-Null
132 }
133 }
134 }
135 }
136 }
137
138 $result = [pscustomobject]@{
139 configPath = (Resolve-Path -LiteralPath $ConfigPath).Path
140 canonical = $canonical
141 filesScanned = $trackedFiles.Count
142 pinsFound = $matchesFound
143 violationCount = $violations.Count
144 violations = $violations
145 allowedFiles = $allowedFiles
146 }
147
148 $result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $resultsPath -Encoding utf8
149
150 if ($violations.Count -gt 0) {
151 Write-Host "PowerShell module pin violations:" -ForegroundColor Red
152 foreach ($v in $violations) {
153 Write-Host (" {0}:{1} {2} expected {3}, found {4}" -f $v.file, $v.line, $v.module, $v.expected, $v.found) -ForegroundColor Red
154 Write-Host (" > {0}" -f $v.snippet) -ForegroundColor DarkGray
155 }
156 Write-Host ""
157 Write-Host "Canonical versions defined in: $ConfigPath" -ForegroundColor Yellow
158 Write-Host "Results written to: $resultsPath" -ForegroundColor Yellow
159 return 1
160 }
161
162 Write-Host ("OK: {0} module pin(s) across {1} file(s) match canonical versions in {2}" -f $matchesFound, $trackedFiles.Count, (Split-Path -Leaf $ConfigPath)) -ForegroundColor Green
163 Write-Host "Results: $resultsPath"
164 return 0
165}
166#endregion
167
168#region Main Execution
169if ($MyInvocation.InvocationName -ne '.') {
170 try {
171 $exitCode = Invoke-PSModulePinScan @PSBoundParameters
172 exit $exitCode
173 }
174 catch {
175 Write-Error -ErrorAction Continue "Test-PSModulePins failed: $($_.Exception.Message)"
176 exit 1
177 }
178}
179#endregion
180