microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/tests/security/Test-PSModulePins.Tests.ps1
317lines · modecode
| 1 | #Requires -Modules Pester |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | |
| 5 | BeforeAll { |
| 6 | . (Join-Path $PSScriptRoot '../../security/Test-PSModulePins.ps1') |
| 7 | |
| 8 | Mock Write-Host {} |
| 9 | |
| 10 | function script:New-PinFixtureRepo { |
| 11 | param( |
| 12 | [Parameter(Mandatory)][string]$Path, |
| 13 | [Parameter(Mandatory)][hashtable]$Files, |
| 14 | [Parameter(Mandatory)][string]$ConfigJson |
| 15 | ) |
| 16 | |
| 17 | New-Item -ItemType Directory -Force -Path $Path | Out-Null |
| 18 | Push-Location $Path |
| 19 | try { |
| 20 | git init --quiet --initial-branch=main 2>&1 | Out-Null |
| 21 | git config user.email 'test@example.com' 2>&1 | Out-Null |
| 22 | git config user.name 'Test' 2>&1 | Out-Null |
| 23 | |
| 24 | $configDir = Join-Path $Path 'scripts/security' |
| 25 | New-Item -ItemType Directory -Force -Path $configDir | Out-Null |
| 26 | $configPath = Join-Path $configDir 'ps-module-versions.json' |
| 27 | Set-Content -LiteralPath $configPath -Value $ConfigJson -Encoding utf8 |
| 28 | |
| 29 | foreach ($rel in $Files.Keys) { |
| 30 | $full = Join-Path $Path $rel |
| 31 | $dir = Split-Path -Parent $full |
| 32 | if ($dir -and -not (Test-Path $dir)) { |
| 33 | New-Item -ItemType Directory -Force -Path $dir | Out-Null |
| 34 | } |
| 35 | Set-Content -LiteralPath $full -Value $Files[$rel] -Encoding utf8 |
| 36 | } |
| 37 | |
| 38 | git add -A 2>&1 | Out-Null |
| 39 | git commit --quiet -m 'fixture' 2>&1 | Out-Null |
| 40 | } finally { |
| 41 | Pop-Location |
| 42 | } |
| 43 | |
| 44 | return $configPath |
| 45 | } |
| 46 | |
| 47 | $script:CanonicalConfig = @' |
| 48 | { |
| 49 | "modules": { |
| 50 | "Pester": { "version": "5.7.1" }, |
| 51 | "PowerShell-Yaml": { "version": "0.4.7" }, |
| 52 | "PSScriptAnalyzer":{ "version": "1.25.0" } |
| 53 | } |
| 54 | } |
| 55 | '@ |
| 56 | } |
| 57 | |
| 58 | Describe 'Invoke-PSModulePinScan' -Tag 'Unit' { |
| 59 | Context 'when all pins match canonical versions' { |
| 60 | It 'Returns 0 and reports zero violations' { |
| 61 | $repo = Join-Path $TestDrive 'happy' |
| 62 | $files = @{ |
| 63 | 'scripts/install.ps1' = @( |
| 64 | "Install-Module -Name Pester -RequiredVersion 5.7.1 -Force" |
| 65 | "Install-Module -Name PowerShell-Yaml -RequiredVersion '0.4.7' -Force" |
| 66 | ) -join "`n" |
| 67 | 'workflows/lint.yml' = " Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser" |
| 68 | } |
| 69 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 70 | |
| 71 | Push-Location $repo |
| 72 | try { |
| 73 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 74 | } finally { |
| 75 | Pop-Location |
| 76 | } |
| 77 | |
| 78 | $exit | Should -Be 0 |
| 79 | |
| 80 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 81 | $results.violationCount | Should -Be 0 |
| 82 | $results.pinsFound | Should -BeGreaterOrEqual 3 |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | Context 'when a pin does not match the canonical version' { |
| 87 | It 'Returns 1 and reports the violation with file, line, module, expected, and found fields' { |
| 88 | $repo = Join-Path $TestDrive 'violation' |
| 89 | $files = @{ |
| 90 | 'scripts/bad.ps1' = @( |
| 91 | "# header" |
| 92 | "Install-Module -Name Pester -RequiredVersion 5.6.0 -Force" |
| 93 | ) -join "`n" |
| 94 | } |
| 95 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 96 | |
| 97 | Push-Location $repo |
| 98 | try { |
| 99 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 100 | } finally { |
| 101 | Pop-Location |
| 102 | } |
| 103 | |
| 104 | $exit | Should -Be 1 |
| 105 | |
| 106 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 107 | $results.violationCount | Should -Be 1 |
| 108 | |
| 109 | $v = $results.violations[0] |
| 110 | $v.file | Should -Be 'scripts/bad.ps1' |
| 111 | $v.module | Should -Be 'Pester' |
| 112 | $v.found | Should -Be '5.6.0' |
| 113 | $v.expected | Should -Be '5.7.1' |
| 114 | $v.line | Should -Be 2 |
| 115 | $v.snippet | Should -Match 'Install-Module' |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | Context 'when the violation is in a path on the allowed list' { |
| 120 | It 'Skips the file and returns 0' { |
| 121 | $repo = Join-Path $TestDrive 'allowed' |
| 122 | $files = @{ |
| 123 | # This path is in the script's hardcoded $allowedFiles list and must be ignored. |
| 124 | 'scripts/tests/security/Test-SHAStaleness.Tests.ps1' = @( |
| 125 | "Install-Module -Name Pester -RequiredVersion 9.9.9 -Force" |
| 126 | ) -join "`n" |
| 127 | } |
| 128 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 129 | |
| 130 | Push-Location $repo |
| 131 | try { |
| 132 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 133 | } finally { |
| 134 | Pop-Location |
| 135 | } |
| 136 | |
| 137 | $exit | Should -Be 0 |
| 138 | |
| 139 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 140 | $results.violationCount | Should -Be 0 |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | Context 'when a #Requires-style hashtable pin is mismatched' { |
| 145 | It 'Detects the violation via the hashtable pattern' { |
| 146 | $repo = Join-Path $TestDrive 'requires' |
| 147 | $files = @{ |
| 148 | 'scripts/needs.ps1' = "#Requires -Modules @{ ModuleName='PSScriptAnalyzer'; RequiredVersion='1.20.0' }" |
| 149 | } |
| 150 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 151 | |
| 152 | Push-Location $repo |
| 153 | try { |
| 154 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 155 | } finally { |
| 156 | Pop-Location |
| 157 | } |
| 158 | |
| 159 | $exit | Should -Be 1 |
| 160 | |
| 161 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 162 | $results.violationCount | Should -Be 1 |
| 163 | $results.violations[0].module | Should -Be 'PSScriptAnalyzer' |
| 164 | $results.violations[0].found | Should -Be '1.20.0' |
| 165 | $results.violations[0].expected | Should -Be '1.25.0' |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | Context 'when the config path does not exist' { |
| 170 | It 'Throws a descriptive error' { |
| 171 | $repo = Join-Path $TestDrive 'missing-config' |
| 172 | New-Item -ItemType Directory -Force -Path $repo | Out-Null |
| 173 | Push-Location $repo |
| 174 | try { |
| 175 | git init --quiet --initial-branch=main 2>&1 | Out-Null |
| 176 | { Invoke-PSModulePinScan -ConfigPath (Join-Path $repo 'nope.json') } | |
| 177 | Should -Throw '*Pin config not found*' |
| 178 | } finally { |
| 179 | Pop-Location |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | Context 'when pins use Import-Module or Update-Module verbs' { |
| 185 | It 'Detects matched and mismatched pins for all three verbs' { |
| 186 | $repo = Join-Path $TestDrive 'verbs' |
| 187 | $files = @{ |
| 188 | 'scripts/verbs.ps1' = @( |
| 189 | "Import-Module -Name Pester -RequiredVersion 5.7.1" |
| 190 | "Update-Module -Name PowerShell-Yaml -RequiredVersion 0.4.6" |
| 191 | ) -join "`n" |
| 192 | } |
| 193 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 194 | |
| 195 | Push-Location $repo |
| 196 | try { |
| 197 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 198 | } finally { |
| 199 | Pop-Location |
| 200 | } |
| 201 | |
| 202 | $exit | Should -Be 1 |
| 203 | |
| 204 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 205 | $results.pinsFound | Should -Be 2 |
| 206 | $results.violationCount | Should -Be 1 |
| 207 | $results.violations[0].module | Should -Be 'PowerShell-Yaml' |
| 208 | $results.violations[0].found | Should -Be '0.4.6' |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | Context 'when multiple violations exist across multiple files' { |
| 213 | It 'Aggregates every violation with correct file and line attribution' { |
| 214 | $repo = Join-Path $TestDrive 'multi' |
| 215 | $files = @{ |
| 216 | 'scripts/a.ps1' = @( |
| 217 | "Install-Module -Name Pester -RequiredVersion 5.0.0 -Force" |
| 218 | "Install-Module -Name PowerShell-Yaml -RequiredVersion 0.4.0 -Force" |
| 219 | ) -join "`n" |
| 220 | 'scripts/b.ps1' = "Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.21.0 -Force" |
| 221 | } |
| 222 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 223 | |
| 224 | Push-Location $repo |
| 225 | try { |
| 226 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 227 | } finally { |
| 228 | Pop-Location |
| 229 | } |
| 230 | |
| 231 | $exit | Should -Be 1 |
| 232 | |
| 233 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 234 | $results.violationCount | Should -Be 3 |
| 235 | |
| 236 | $byFile = $results.violations | Group-Object file |
| 237 | ($byFile | Where-Object Name -eq 'scripts/a.ps1').Count | Should -Be 2 |
| 238 | ($byFile | Where-Object Name -eq 'scripts/b.ps1').Count | Should -Be 1 |
| 239 | |
| 240 | $aLines = ($results.violations | Where-Object file -eq 'scripts/a.ps1' | Sort-Object line).line |
| 241 | $aLines | Should -Be @(1, 2) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | Context 'when a violation is in an untracked file' { |
| 246 | It 'Ignores the untracked file and returns 0' { |
| 247 | $repo = Join-Path $TestDrive 'untracked' |
| 248 | $files = @{ |
| 249 | 'scripts/clean.ps1' = "Install-Module -Name Pester -RequiredVersion 5.7.1 -Force" |
| 250 | } |
| 251 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 252 | |
| 253 | # Add an untracked file containing a clear violation after the fixture commit. |
| 254 | Set-Content -LiteralPath (Join-Path $repo 'scripts/untracked.ps1') ` |
| 255 | -Value "Install-Module -Name Pester -RequiredVersion 9.9.9 -Force" -Encoding utf8 |
| 256 | |
| 257 | Push-Location $repo |
| 258 | try { |
| 259 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 260 | } finally { |
| 261 | Pop-Location |
| 262 | } |
| 263 | |
| 264 | $exit | Should -Be 0 |
| 265 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 266 | $results.violationCount | Should -Be 0 |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | Context 'when a violation appears in a file with an unsupported extension' { |
| 271 | It 'Skips the file based on extension filtering' { |
| 272 | $repo = Join-Path $TestDrive 'ext' |
| 273 | $files = @{ |
| 274 | 'notes.txt' = "Install-Module -Name Pester -RequiredVersion 9.9.9 -Force" |
| 275 | 'scripts/keep.ps1' = "Install-Module -Name Pester -RequiredVersion 5.7.1 -Force" |
| 276 | } |
| 277 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 278 | |
| 279 | Push-Location $repo |
| 280 | try { |
| 281 | $exit = Invoke-PSModulePinScan -ConfigPath $configPath |
| 282 | } finally { |
| 283 | Pop-Location |
| 284 | } |
| 285 | |
| 286 | $exit | Should -Be 0 |
| 287 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 288 | $results.violationCount | Should -Be 0 |
| 289 | $results.pinsFound | Should -Be 1 |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | Context 'results JSON metadata' { |
| 294 | It 'Records configPath, canonical map, filesScanned, and allowedFiles' { |
| 295 | $repo = Join-Path $TestDrive 'metadata' |
| 296 | $files = @{ |
| 297 | 'scripts/install.ps1' = "Install-Module -Name Pester -RequiredVersion 5.7.1 -Force" |
| 298 | } |
| 299 | $configPath = New-PinFixtureRepo -Path $repo -Files $files -ConfigJson $script:CanonicalConfig |
| 300 | |
| 301 | Push-Location $repo |
| 302 | try { |
| 303 | Invoke-PSModulePinScan -ConfigPath $configPath | Out-Null |
| 304 | } finally { |
| 305 | Pop-Location |
| 306 | } |
| 307 | |
| 308 | $results = Get-Content -Raw (Join-Path $repo 'logs/ps-module-pins-results.json') | ConvertFrom-Json |
| 309 | $results.configPath | Should -Match 'ps-module-versions\.json$' |
| 310 | $results.canonical.Pester | Should -Be '5.7.1' |
| 311 | $results.canonical.'PowerShell-Yaml' | Should -Be '0.4.7' |
| 312 | $results.filesScanned | Should -BeGreaterOrEqual 1 |
| 313 | $results.allowedFiles | Should -Contain 'scripts/security/ps-module-versions.json' |
| 314 | $results.allowedFiles | Should -Contain 'scripts/security/Test-PSModulePins.ps1' |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | |