microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v2.0.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/Test-DependencyPinning.Tests.ps1

481lines · modecode

1#Requires -Modules Pester
2
3BeforeAll {
4 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
5
6 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
7 Import-Module $mockPath -Force
8
9 # Fixture paths
10 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Workflows'
11 $script:SecurityFixturesPath = Join-Path $PSScriptRoot '../Fixtures/Security'
12}
13
14Describe 'Test-SHAPinning' -Tag 'Unit' {
15 Context 'Valid SHA references for github-actions' {
16 It 'Returns true for valid 40-char lowercase SHA' {
17 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
18 }
19
20 It 'Returns true for valid 40-char mixed case SHA' {
21 Test-SHAPinning -Version 'A5AC7E51B41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
22 }
23 }
24
25 Context 'Invalid SHA references for github-actions' {
26 It 'Returns false for tag reference' {
27 Test-SHAPinning -Version 'v4' -Type 'github-actions' | Should -BeFalse
28 }
29
30 It 'Returns false for branch reference' {
31 Test-SHAPinning -Version 'main' -Type 'github-actions' | Should -BeFalse
32 }
33
34 It 'Returns false for 39-char reference' {
35 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc2' -Type 'github-actions' | Should -BeFalse
36 }
37
38 It 'Returns false for 41-char reference' {
39 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc291' -Type 'github-actions' | Should -BeFalse
40 }
41
42 It 'Returns false for non-hex characters' {
43 Test-SHAPinning -Version 'g5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeFalse
44 }
45 }
46
47 Context 'Unknown type' {
48 It 'Returns false for unknown dependency type' {
49 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'unknown-type' | Should -BeFalse
50 }
51 }
52}
53
54Describe 'Test-ShellDownloadSecurity' -Tag 'Unit' {
55 Context 'Insecure downloads' {
56 It 'Detects curl without checksum verification' {
57 $testFile = Join-Path $script:SecurityFixturesPath 'insecure-download.sh'
58 $fileInfo = @{
59 Path = $testFile
60 Type = 'shell-downloads'
61 RelativePath = 'insecure-download.sh'
62 }
63 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
64 $result | Should -Not -BeNullOrEmpty
65 $result[0].Severity | Should -Be 'warning'
66 }
67 }
68
69 Context 'File not found' {
70 It 'Returns empty array for non-existent file' {
71 $fileInfo = @{
72 Path = 'TestDrive:/nonexistent/file.sh'
73 Type = 'shell-downloads'
74 RelativePath = 'nonexistent/file.sh'
75 }
76 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
77 $result | Should -BeNullOrEmpty
78 }
79 }
80}
81
82Describe 'Get-DependencyViolation' -Tag 'Unit' {
83 Context 'Pinned workflows' {
84 It 'Returns no violations for fully pinned workflow' {
85 $pinnedPath = Join-Path $script:FixturesPath 'pinned-workflow.yml'
86 $fileInfo = @{
87 Path = $pinnedPath
88 Type = 'github-actions'
89 RelativePath = 'pinned-workflow.yml'
90 }
91 $result = Get-DependencyViolation -FileInfo $fileInfo
92 $result | Should -BeNullOrEmpty
93 }
94 }
95
96 Context 'Unpinned workflows' {
97 It 'Detects unpinned action references' {
98 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
99 $fileInfo = @{
100 Path = $unpinnedPath
101 Type = 'github-actions'
102 RelativePath = 'unpinned-workflow.yml'
103 }
104 $result = Get-DependencyViolation -FileInfo $fileInfo
105 $result | Should -Not -BeNullOrEmpty
106 $result.Count | Should -BeGreaterThan 0
107 }
108
109 It 'Returns correct violation type for unpinned actions' {
110 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
111 $fileInfo = @{
112 Path = $unpinnedPath
113 Type = 'github-actions'
114 RelativePath = 'unpinned-workflow.yml'
115 }
116 $result = Get-DependencyViolation -FileInfo $fileInfo
117 $result[0].Type | Should -Be 'github-actions'
118 }
119 }
120
121 Context 'Mixed workflows' {
122 It 'Detects only unpinned actions in mixed workflow' {
123 $mixedPath = Join-Path $script:FixturesPath 'mixed-pinning-workflow.yml'
124 $fileInfo = @{
125 Path = $mixedPath
126 Type = 'github-actions'
127 RelativePath = 'mixed-pinning-workflow.yml'
128 }
129 $result = Get-DependencyViolation -FileInfo $fileInfo
130 $result | Should -Not -BeNullOrEmpty
131 # Should only detect the unpinned setup-node action
132 $result.Name | Should -Contain 'actions/setup-node'
133 }
134 }
135
136 Context 'Non-existent file' {
137 It 'Returns empty array for non-existent file' {
138 $fileInfo = @{
139 Path = 'TestDrive:/nonexistent/file.yml'
140 Type = 'github-actions'
141 RelativePath = 'file.yml'
142 }
143 $result = Get-DependencyViolation -FileInfo $fileInfo
144 $result | Should -BeNullOrEmpty
145 }
146 }
147}
148
149Describe 'Export-ComplianceReport' -Tag 'Unit' {
150 BeforeEach {
151 $script:TestOutputPath = Join-Path $TestDrive 'report'
152 New-Item -ItemType Directory -Path $script:TestOutputPath -Force | Out-Null
153
154 # Create a proper ComplianceReport class instance
155 $script:MockReport = [ComplianceReport]::new()
156 $script:MockReport.ScanPath = $script:FixturesPath
157 $script:MockReport.ComplianceScore = 50
158 $script:MockReport.TotalFiles = 3
159 $script:MockReport.ScannedFiles = 3
160 $script:MockReport.TotalDependencies = 4
161 $script:MockReport.PinnedDependencies = 2
162 $script:MockReport.UnpinnedDependencies = 2
163 $script:MockReport.Violations = @(
164 [PSCustomObject]@{
165 File = 'unpinned-workflow.yml'
166 Line = 10
167 Type = 'github-actions'
168 Name = 'actions/checkout'
169 Version = 'v4'
170 Severity = 'High'
171 Description = 'Unpinned dependency'
172 Remediation = 'Pin to SHA'
173 }
174 )
175 $script:MockReport.Summary = @{
176 'github-actions' = @{
177 Total = 4
178 High = 2
179 Medium = 0
180 Low = 0
181 }
182 }
183 }
184
185 Context 'JSON format' {
186 It 'Generates valid JSON report' {
187 $outputFile = Join-Path $script:TestOutputPath 'report.json'
188
189 Export-ComplianceReport -Report $script:MockReport -Format 'json' -OutputPath $outputFile
190
191 Test-Path $outputFile | Should -BeTrue
192 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
193 $content | Should -Not -BeNullOrEmpty
194 }
195 }
196
197 Context 'SARIF format' {
198 It 'Generates valid SARIF report' {
199 $outputFile = Join-Path $script:TestOutputPath 'report.sarif'
200
201 Export-ComplianceReport -Report $script:MockReport -Format 'sarif' -OutputPath $outputFile
202
203 Test-Path $outputFile | Should -BeTrue
204 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
205 $content.'$schema' | Should -Match 'sarif'
206 }
207 }
208
209 Context 'Table format' {
210 It 'Generates table output without error' {
211 $outputFile = Join-Path $script:TestOutputPath 'report.txt'
212
213 { Export-ComplianceReport -Report $script:MockReport -Format 'table' -OutputPath $outputFile } | Should -Not -Throw
214 Test-Path $outputFile | Should -BeTrue
215 }
216 }
217
218 Context 'CSV format' {
219 It 'Generates CSV report' {
220 $outputFile = Join-Path $script:TestOutputPath 'report.csv'
221
222 Export-ComplianceReport -Report $script:MockReport -Format 'csv' -OutputPath $outputFile
223
224 Test-Path $outputFile | Should -BeTrue
225 }
226 }
227
228 Context 'Markdown format' {
229 It 'Generates Markdown report' {
230 $outputFile = Join-Path $script:TestOutputPath 'report.md'
231
232 Export-ComplianceReport -Report $script:MockReport -Format 'markdown' -OutputPath $outputFile
233
234 Test-Path $outputFile | Should -BeTrue
235 $content = Get-Content $outputFile -Raw
236 $content | Should -Match '# Dependency Pinning Compliance Report'
237 }
238 }
239}
240
241Describe 'ExcludePaths Filtering Logic' -Tag 'Unit' {
242 Context 'Pattern matching with -notlike operator' {
243 It 'Excludes paths containing pattern using -notlike wildcard' {
244 # Test the exclusion logic used in Get-FilesToScan:
245 # $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
246 $testPaths = @(
247 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
248 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
249 )
250
251 $exclude = 'vendor'
252 $filtered = $testPaths | Where-Object { $_.FullName -notlike "*$exclude*" }
253
254 $filtered.Count | Should -Be 1
255 $filtered[0].FullName | Should -Not -Match 'vendor'
256 }
257
258 It 'Excludes multiple patterns correctly' {
259 $testPaths = @(
260 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
261 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
262 @{ FullName = 'C:\repo\node_modules\pkg\workflow.yml' }
263 )
264
265 $excludePatterns = @('vendor', 'node_modules')
266 $filtered = $testPaths
267 foreach ($exclude in $excludePatterns) {
268 $filtered = @($filtered | Where-Object { $_.FullName -notlike "*$exclude*" })
269 }
270
271 $filtered.Count | Should -Be 1
272 $filtered[0].FullName | Should -Be 'C:\repo\.github\workflows\test.yml'
273 }
274 }
275
276 Context 'Processes all files when ExcludePatterns is empty' {
277 It 'Returns all paths when no exclusion patterns provided' {
278 $testPaths = @(
279 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
280 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
281 )
282
283 $excludePatterns = @()
284 $filtered = $testPaths
285 if ($excludePatterns) {
286 foreach ($exclude in $excludePatterns) {
287 $filtered = $filtered | Where-Object { $_.FullName -notlike "*$exclude*" }
288 }
289 }
290
291 $filtered.Count | Should -Be 2
292 }
293 }
294
295 Context 'Comma-separated pattern parsing in main script' {
296 It 'Parses comma-separated exclude paths correctly' {
297 # Test the pattern used in main execution: $ExcludePaths.Split(',')
298 $excludePathsParam = 'vendor,node_modules,dist'
299 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
300
301 $patterns.Count | Should -Be 3
302 $patterns | Should -Contain 'vendor'
303 $patterns | Should -Contain 'node_modules'
304 $patterns | Should -Contain 'dist'
305 }
306
307 It 'Handles single pattern without comma' {
308 $excludePathsParam = 'vendor'
309 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
310
311 $patterns.Count | Should -Be 1
312 $patterns | Should -Contain 'vendor'
313 }
314
315 It 'Handles empty exclude paths' {
316 $excludePathsParam = ''
317 $patterns = if ($excludePathsParam) { $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
318
319 $patterns.Count | Should -Be 0
320 }
321 }
322
323 Context 'Pattern matching behavior' {
324 It 'Uses -notlike with wildcard for exclusion' {
325 $filePath = 'C:\repo\vendor\.github\workflows\test.yml'
326 $pattern = 'vendor'
327
328 # This matches how Get-FilesToScan uses: $_.FullName -notlike "*$exclude*"
329 $filePath -notlike "*$pattern*" | Should -BeFalse
330 }
331
332 It 'Passes through non-matching paths' {
333 $filePath = 'C:\repo\.github\workflows\main.yml'
334 $pattern = 'vendor'
335
336 $filePath -notlike "*$pattern*" | Should -BeTrue
337 }
338 }
339}
340
341Describe 'Get-NpmDependencyViolations' -Tag 'Unit' {
342 BeforeAll {
343 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
344 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Npm'
345 }
346
347 Context 'Metadata-only package.json' {
348 It 'Returns zero violations for package with no dependencies' {
349 $fileInfo = @{
350 Path = Join-Path $script:FixturesPath 'metadata-only-package.json'
351 Type = 'npm'
352 RelativePath = 'metadata-only-package.json'
353 }
354
355 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
356
357 $violations.Count | Should -Be 0
358 }
359 }
360
361 Context 'Package.json with dependencies' {
362 It 'Detects unpinned dependencies in all sections' {
363 $fileInfo = @{
364 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
365 Type = 'npm'
366 RelativePath = 'with-dependencies-package.json'
367 }
368
369 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
370
371 $violations.Count | Should -BeGreaterThan 0
372 }
373
374 It 'Identifies correct dependency sections' {
375 $fileInfo = @{
376 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
377 Type = 'npm'
378 RelativePath = 'with-dependencies-package.json'
379 }
380
381 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
382 $sections = $violations | ForEach-Object { $_.Metadata.Section } | Sort-Object -Unique
383
384 $sections | Should -Contain 'dependencies'
385 $sections | Should -Contain 'devDependencies'
386 }
387
388 It 'Captures package name and version in violations' {
389 $fileInfo = @{
390 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
391 Type = 'npm'
392 RelativePath = 'with-dependencies-package.json'
393 }
394
395 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
396 $lodashViolation = $violations | Where-Object { $_.Name -eq 'lodash' }
397
398 $lodashViolation | Should -Not -BeNullOrEmpty
399 $lodashViolation.Name | Should -Be 'lodash'
400 $lodashViolation.Version | Should -Be '^4.17.21'
401 }
402 }
403
404 Context 'Non-existent file' {
405 It 'Returns empty array for missing file' {
406 $fileInfo = @{
407 Path = 'C:\nonexistent\package.json'
408 Type = 'npm'
409 RelativePath = 'nonexistent/package.json'
410 }
411
412 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
413
414 $violations.Count | Should -Be 0
415 }
416 }
417
418 Context 'When package.json contains invalid JSON' {
419 BeforeAll {
420 $script:invalidJsonPath = Join-Path $script:FixturesPath 'invalid-json-package.json'
421 }
422
423 It 'Returns empty violations array on parse failure' {
424 $fileInfo = @{
425 Path = $script:invalidJsonPath
426 Type = 'npm'
427 RelativePath = 'invalid-json-package.json'
428 }
429
430 $violations = @(Get-NpmDependencyViolations -FileInfo $fileInfo)
431
432 $violations | Should -HaveCount 0
433 }
434
435 It 'Emits a warning about parse failure' {
436 $fileInfo = @{
437 Path = $script:invalidJsonPath
438 Type = 'npm'
439 RelativePath = 'invalid-json-package.json'
440 }
441
442 $warnings = Get-NpmDependencyViolations -FileInfo $fileInfo 3>&1
443
444 $warnings | Should -Not -BeNullOrEmpty
445 $warnings | Should -Match 'Failed to parse.*as JSON'
446 }
447 }
448
449 Context 'When package.json contains empty or whitespace versions' {
450 BeforeAll {
451 $script:emptyVersionPath = Join-Path $script:FixturesPath 'empty-version-package.json'
452 }
453
454 It 'Skips dependencies with empty versions' {
455 $fileInfo = @{
456 Path = $script:emptyVersionPath
457 Type = 'npm'
458 RelativePath = 'empty-version-package.json'
459 }
460
461 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
462 $packageNames = $violations | ForEach-Object { $_.Name }
463
464 $packageNames | Should -Not -Contain 'empty-version'
465 $packageNames | Should -Not -Contain 'whitespace-version'
466 }
467
468 It 'Reports violations for valid non-pinned versions in same file' {
469 $fileInfo = @{
470 Path = $script:emptyVersionPath
471 Type = 'npm'
472 RelativePath = 'empty-version-package.json'
473 }
474
475 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
476
477 $violations.Count | Should -BeGreaterThan 0
478 $violations | Where-Object { $_.Name -eq 'valid-package' } | Should -Not -BeNullOrEmpty
479 }
480 }
481}
482