microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/621-ai-artifacts

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

851lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5BeforeAll {
6 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
7
8 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
9 Import-Module $mockPath -Force
10
11 # Fixture paths
12 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Workflows'
13 $script:SecurityFixturesPath = Join-Path $PSScriptRoot '../Fixtures/Security'
14}
15
16Describe 'Test-SHAPinning' -Tag 'Unit' {
17 Context 'Valid SHA references for github-actions' {
18 It 'Returns true for valid 40-char lowercase SHA' {
19 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
20 }
21
22 It 'Returns true for valid 40-char mixed case SHA' {
23 Test-SHAPinning -Version 'A5AC7E51B41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
24 }
25 }
26
27 Context 'Invalid SHA references for github-actions' {
28 It 'Returns false for tag reference' {
29 Test-SHAPinning -Version 'v4' -Type 'github-actions' | Should -BeFalse
30 }
31
32 It 'Returns false for branch reference' {
33 Test-SHAPinning -Version 'main' -Type 'github-actions' | Should -BeFalse
34 }
35
36 It 'Returns false for 39-char reference' {
37 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc2' -Type 'github-actions' | Should -BeFalse
38 }
39
40 It 'Returns false for 41-char reference' {
41 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc291' -Type 'github-actions' | Should -BeFalse
42 }
43
44 It 'Returns false for non-hex characters' {
45 Test-SHAPinning -Version 'g5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeFalse
46 }
47 }
48
49 Context 'Unknown type' {
50 It 'Returns false for unknown dependency type' {
51 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'unknown-type' | Should -BeFalse
52 }
53 }
54}
55
56Describe 'Test-ShellDownloadSecurity' -Tag 'Unit' {
57 Context 'Insecure downloads' {
58 It 'Detects curl without checksum verification' {
59 $testFile = Join-Path $script:SecurityFixturesPath 'insecure-download.sh'
60 $fileInfo = @{
61 Path = $testFile
62 Type = 'shell-downloads'
63 RelativePath = 'insecure-download.sh'
64 }
65 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
66 $result | Should -Not -BeNullOrEmpty
67 $result[0].Severity | Should -Be 'warning'
68 }
69 }
70
71 Context 'File not found' {
72 It 'Returns empty array for non-existent file' {
73 $fileInfo = @{
74 Path = 'TestDrive:/nonexistent/file.sh'
75 Type = 'shell-downloads'
76 RelativePath = 'nonexistent/file.sh'
77 }
78 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
79 $result | Should -BeNullOrEmpty
80 }
81 }
82}
83
84Describe 'Get-DependencyViolation' -Tag 'Unit' {
85 Context 'Pinned workflows' {
86 It 'Returns no violations for fully pinned workflow' {
87 $pinnedPath = Join-Path $script:FixturesPath 'pinned-workflow.yml'
88 $fileInfo = @{
89 Path = $pinnedPath
90 Type = 'github-actions'
91 RelativePath = 'pinned-workflow.yml'
92 }
93 $result = Get-DependencyViolation -FileInfo $fileInfo
94 $result | Should -BeNullOrEmpty
95 }
96 }
97
98 Context 'Unpinned workflows' {
99 It 'Detects unpinned action references' {
100 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
101 $fileInfo = @{
102 Path = $unpinnedPath
103 Type = 'github-actions'
104 RelativePath = 'unpinned-workflow.yml'
105 }
106 $result = Get-DependencyViolation -FileInfo $fileInfo
107 $result | Should -Not -BeNullOrEmpty
108 $result.Count | Should -BeGreaterThan 0
109 }
110
111 It 'Returns correct violation type for unpinned actions' {
112 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
113 $fileInfo = @{
114 Path = $unpinnedPath
115 Type = 'github-actions'
116 RelativePath = 'unpinned-workflow.yml'
117 }
118 $result = Get-DependencyViolation -FileInfo $fileInfo
119 $result[0].Type | Should -Be 'github-actions'
120 }
121 }
122
123 Context 'Mixed workflows' {
124 It 'Detects only unpinned actions in mixed workflow' {
125 $mixedPath = Join-Path $script:FixturesPath 'mixed-pinning-workflow.yml'
126 $fileInfo = @{
127 Path = $mixedPath
128 Type = 'github-actions'
129 RelativePath = 'mixed-pinning-workflow.yml'
130 }
131 $result = Get-DependencyViolation -FileInfo $fileInfo
132 $result | Should -Not -BeNullOrEmpty
133 # Should only detect the unpinned setup-node action
134 $result.Name | Should -Contain 'actions/setup-node'
135 }
136 }
137
138 Context 'Non-existent file' {
139 It 'Returns empty array for non-existent file' {
140 $fileInfo = @{
141 Path = 'TestDrive:/nonexistent/file.yml'
142 Type = 'github-actions'
143 RelativePath = 'file.yml'
144 }
145 $result = Get-DependencyViolation -FileInfo $fileInfo
146 $result | Should -BeNullOrEmpty
147 }
148 }
149}
150
151Describe 'Export-ComplianceReport' -Tag 'Unit' {
152 BeforeEach {
153 $script:TestOutputPath = Join-Path $TestDrive 'report'
154 New-Item -ItemType Directory -Path $script:TestOutputPath -Force | Out-Null
155
156 # Create a proper ComplianceReport class instance
157 $script:MockReport = [ComplianceReport]::new()
158 $script:MockReport.ScanPath = $script:FixturesPath
159 $script:MockReport.ComplianceScore = 50
160 $script:MockReport.TotalFiles = 3
161 $script:MockReport.ScannedFiles = 3
162 $script:MockReport.TotalDependencies = 4
163 $script:MockReport.PinnedDependencies = 2
164 $script:MockReport.UnpinnedDependencies = 2
165 $script:MockReport.Violations = @(
166 [PSCustomObject]@{
167 File = 'unpinned-workflow.yml'
168 Line = 10
169 Type = 'github-actions'
170 Name = 'actions/checkout'
171 Version = 'v4'
172 Severity = 'High'
173 Description = 'Unpinned dependency'
174 Remediation = 'Pin to SHA'
175 }
176 )
177 $script:MockReport.Summary = @{
178 'github-actions' = @{
179 Total = 4
180 High = 2
181 Medium = 0
182 Low = 0
183 }
184 }
185 }
186
187 Context 'JSON format' {
188 It 'Generates valid JSON report' {
189 $outputFile = Join-Path $script:TestOutputPath 'report.json'
190
191 Export-ComplianceReport -Report $script:MockReport -Format 'json' -OutputPath $outputFile
192
193 Test-Path $outputFile | Should -BeTrue
194 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
195 $content | Should -Not -BeNullOrEmpty
196 }
197 }
198
199 Context 'SARIF format' {
200 It 'Generates valid SARIF report' {
201 $outputFile = Join-Path $script:TestOutputPath 'report.sarif'
202
203 Export-ComplianceReport -Report $script:MockReport -Format 'sarif' -OutputPath $outputFile
204
205 Test-Path $outputFile | Should -BeTrue
206 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
207 $content.'$schema' | Should -Match 'sarif'
208 }
209 }
210
211 Context 'Table format' {
212 It 'Generates table output without error' {
213 $outputFile = Join-Path $script:TestOutputPath 'report.txt'
214
215 { Export-ComplianceReport -Report $script:MockReport -Format 'table' -OutputPath $outputFile } | Should -Not -Throw
216 Test-Path $outputFile | Should -BeTrue
217 }
218 }
219
220 Context 'CSV format' {
221 It 'Generates CSV report' {
222 $outputFile = Join-Path $script:TestOutputPath 'report.csv'
223
224 Export-ComplianceReport -Report $script:MockReport -Format 'csv' -OutputPath $outputFile
225
226 Test-Path $outputFile | Should -BeTrue
227 }
228 }
229
230 Context 'Markdown format' {
231 It 'Generates Markdown report' {
232 $outputFile = Join-Path $script:TestOutputPath 'report.md'
233
234 Export-ComplianceReport -Report $script:MockReport -Format 'markdown' -OutputPath $outputFile
235
236 Test-Path $outputFile | Should -BeTrue
237 $content = Get-Content $outputFile -Raw
238 $content | Should -Match '# Dependency Pinning Compliance Report'
239 }
240 }
241}
242
243Describe 'ExcludePaths Filtering Logic' -Tag 'Unit' {
244 Context 'Pattern matching with -notlike operator' {
245 It 'Excludes paths containing pattern using -notlike wildcard' {
246 # Test the exclusion logic used in Get-FilesToScan:
247 # $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
248 $testPaths = @(
249 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
250 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
251 )
252
253 $exclude = 'vendor'
254 $filtered = $testPaths | Where-Object { $_.FullName -notlike "*$exclude*" }
255
256 $filtered.Count | Should -Be 1
257 $filtered[0].FullName | Should -Not -Match 'vendor'
258 }
259
260 It 'Excludes multiple patterns correctly' {
261 $testPaths = @(
262 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
263 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
264 @{ FullName = 'C:\repo\node_modules\pkg\workflow.yml' }
265 )
266
267 $excludePatterns = @('vendor', 'node_modules')
268 $filtered = $testPaths
269 foreach ($exclude in $excludePatterns) {
270 $filtered = @($filtered | Where-Object { $_.FullName -notlike "*$exclude*" })
271 }
272
273 $filtered.Count | Should -Be 1
274 $filtered[0].FullName | Should -Be 'C:\repo\.github\workflows\test.yml'
275 }
276 }
277
278 Context 'Processes all files when ExcludePatterns is empty' {
279 It 'Returns all paths when no exclusion patterns provided' {
280 $testPaths = @(
281 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
282 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
283 )
284
285 $excludePatterns = @()
286 $filtered = $testPaths
287 if ($excludePatterns) {
288 foreach ($exclude in $excludePatterns) {
289 $filtered = $filtered | Where-Object { $_.FullName -notlike "*$exclude*" }
290 }
291 }
292
293 $filtered.Count | Should -Be 2
294 }
295 }
296
297 Context 'Comma-separated pattern parsing in main script' {
298 It 'Parses comma-separated exclude paths correctly' {
299 # Test the pattern used in main execution: $ExcludePaths.Split(',')
300 $excludePathsParam = 'vendor,node_modules,dist'
301 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
302
303 $patterns.Count | Should -Be 3
304 $patterns | Should -Contain 'vendor'
305 $patterns | Should -Contain 'node_modules'
306 $patterns | Should -Contain 'dist'
307 }
308
309 It 'Handles single pattern without comma' {
310 $excludePathsParam = 'vendor'
311 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
312
313 $patterns.Count | Should -Be 1
314 $patterns | Should -Contain 'vendor'
315 }
316
317 It 'Handles empty exclude paths' {
318 $excludePathsParam = ''
319 $patterns = if ($excludePathsParam) { $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
320
321 $patterns.Count | Should -Be 0
322 }
323 }
324
325 Context 'Pattern matching behavior' {
326 It 'Uses -notlike with wildcard for exclusion' {
327 $filePath = 'C:\repo\vendor\.github\workflows\test.yml'
328 $pattern = 'vendor'
329
330 # This matches how Get-FilesToScan uses: $_.FullName -notlike "*$exclude*"
331 $filePath -notlike "*$pattern*" | Should -BeFalse
332 }
333
334 It 'Passes through non-matching paths' {
335 $filePath = 'C:\repo\.github\workflows\main.yml'
336 $pattern = 'vendor'
337
338 $filePath -notlike "*$pattern*" | Should -BeTrue
339 }
340 }
341}
342
343Describe 'Dot-sourced execution protection' -Tag 'Unit' {
344 Context 'When script is dot-sourced' {
345 It 'Does not execute main block when dot-sourced' {
346 # Arrange
347 $testScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
348 $tempOutputPath = Join-Path $TestDrive 'dot-source-test.json'
349
350 # Act - Invoke in new process with dot-sourcing simulation
351 $scriptBlock = ". '$testScript' -OutputPath '$tempOutputPath'; [System.IO.File]::Exists('$tempOutputPath')"
352 pwsh -Command $scriptBlock 2>&1 | Out-Null
353
354 # Assert - Main execution should be skipped, no output file created
355 Test-Path $tempOutputPath | Should -BeFalse
356 }
357
358 }
359}
360
361Describe 'GitHub Actions error annotation' {
362 BeforeAll {
363 $script:OriginalGHA = $env:GITHUB_ACTIONS
364 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
365 }
366
367 AfterAll {
368 if ($null -eq $script:OriginalGHA) {
369 Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue
370 } else {
371 $env:GITHUB_ACTIONS = $script:OriginalGHA
372 }
373 }
374
375 Context 'Error handling with GitHub Actions' {
376 It 'Outputs GitHub error annotation on failure' {
377 # Arrange - Create a corrupted workflow file that will trigger an error
378 $testWorkflowDir = Join-Path $TestDrive 'test-workflows'
379 New-Item -ItemType Directory -Path (Join-Path $testWorkflowDir '.github/workflows') -Force | Out-Null
380 $corruptedFile = Join-Path $testWorkflowDir '.github/workflows/test.yml'
381 "uses: actions/checkout@invalid!!!" | Out-File -FilePath $corruptedFile -Encoding UTF8
382
383 # Act - Run script in new process with GITHUB_ACTIONS set
384 $scriptCommand = @"
385`$env:GITHUB_ACTIONS = 'true'
386& '$script:TestScript' -Path '$testWorkflowDir' -Format 'json' -OutputPath '$TestDrive/gha-test.json' -FailOnUnpinned 2>&1
387"@
388 $output = pwsh -Command $scriptCommand
389
390 # Assert - Should contain GitHub Actions error annotation or error output
391 # The script should execute and potentially generate warnings/errors
392 $output | Should -Not -BeNullOrEmpty
393 }
394 }
395}
396
397Describe 'Get-ComplianceReportData' -Tag 'Unit' {
398 BeforeAll {
399 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
400 }
401
402 Context 'Array coercion operations' {
403 It 'Handles empty violations array' {
404 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations @() -ScannedFiles @()
405
406 $result.TotalDependencies | Should -Be 0
407 $result.UnpinnedDependencies | Should -Be 0
408 $result.PinnedDependencies | Should -Be 0
409 $result.ComplianceScore | Should -Be 100.0
410 }
411
412 It 'Counts violations correctly with array coercion' {
413 $v1 = [DependencyViolation]::new()
414 $v1.Type = 'github-actions'
415 $v1.Severity = 'High'
416
417 $v2 = [DependencyViolation]::new()
418 $v2.Type = 'github-actions'
419 $v2.Severity = 'Medium'
420
421 $v3 = [DependencyViolation]::new()
422 $v3.Type = 'npm'
423 $v3.Severity = 'High'
424
425 $violations = @($v1, $v2, $v3)
426 $scannedFiles = @(@{ Path = 'test1.yml' }, @{ Path = 'test2.json' })
427
428 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
429
430 $result.TotalDependencies | Should -Be 3
431 $result.UnpinnedDependencies | Should -Be 3
432 }
433
434 It 'Groups violations by type with array coercion' {
435 $v1 = [DependencyViolation]::new()
436 $v1.Type = 'github-actions'
437 $v1.Severity = 'High'
438
439 $v2 = [DependencyViolation]::new()
440 $v2.Type = 'github-actions'
441 $v2.Severity = 'Low'
442
443 $v3 = [DependencyViolation]::new()
444 $v3.Type = 'npm'
445 $v3.Severity = 'Medium'
446
447 $violations = @($v1, $v2, $v3)
448 $scannedFiles = @(@{ Path = 'test.yml' })
449
450 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
451
452 $result.Summary.Keys | Should -Contain 'github-actions'
453 $result.Summary.Keys | Should -Contain 'npm'
454 $result.Summary['github-actions'].Total | Should -Be 2
455 $result.Summary['npm'].Total | Should -Be 1
456 }
457
458 It 'Counts severity levels correctly with array coercion' {
459 $violations = @()
460 for ($i = 0; $i -lt 4; $i++) {
461 $v = [DependencyViolation]::new()
462 $v.Type = 'github-actions'
463 $v.Severity = switch ($i) {
464 0 { 'High' }
465 1 { 'High' }
466 2 { 'Medium' }
467 3 { 'Low' }
468 }
469 $violations += $v
470 }
471 $scannedFiles = @(@{ Path = 'test.yml' })
472
473 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
474
475 $result.Summary['github-actions'].High | Should -Be 2
476 $result.Summary['github-actions'].Medium | Should -Be 1
477 $result.Summary['github-actions'].Low | Should -Be 1
478 }
479
480 It 'Handles single violation without PowerShell unrolling' {
481 $v = [DependencyViolation]::new()
482 $v.Type = 'github-actions'
483 $v.Severity = 'High'
484
485 $violations = @($v)
486 $scannedFiles = @(@{ Path = 'test.yml' })
487
488 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
489
490 $result.TotalDependencies | Should -Be 1
491 $result.Summary['github-actions'].Total | Should -Be 1
492 $result.Summary['github-actions'].High | Should -Be 1
493 }
494 }
495}
496
497Describe 'Main Script Execution' {
498 BeforeAll {
499 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
500 $script:TestWorkspaceDir = Join-Path $TestDrive 'test-workspace'
501 New-Item -ItemType Directory -Path $script:TestWorkspaceDir -Force | Out-Null
502
503 # Create .github/workflows directory
504 $workflowDir = Join-Path $script:TestWorkspaceDir '.github/workflows'
505 New-Item -ItemType Directory -Path $workflowDir -Force | Out-Null
506 }
507
508 Context 'Array coercion in main execution block' {
509 It 'Executes array coercion when scanning files' {
510 # Create test workflow file
511 $workflowContent = @'
512name: Test
513on: push
514jobs:
515 test:
516 runs-on: ubuntu-latest
517 steps:
518 - uses: actions/checkout@v4
519'@
520 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/test.yml') -Value $workflowContent
521
522 $jsonPath = Join-Path $TestDrive 'scan-output.json'
523
524 # Execute script with array coercion operations
525 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
526
527 # Verify output was created (proves array operations executed)
528 Test-Path $jsonPath | Should -BeTrue
529 $result = Get-Content $jsonPath | ConvertFrom-Json
530 $result.PSObject.Properties.Name | Should -Contain 'ComplianceScore'
531 }
532
533 It 'Handles empty scan results with array coercion' {
534 # Remove workflow files
535 Remove-Item -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/*.yml') -Force -ErrorAction SilentlyContinue
536
537 # Create pinned workflow
538 $pinnedContent = @'
539name: Pinned
540on: push
541jobs:
542 test:
543 runs-on: ubuntu-latest
544 steps:
545 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
546'@
547 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/pinned.yml') -Value $pinnedContent
548
549 $jsonPath = Join-Path $TestDrive 'empty-output.json'
550
551 # Execute with all dependencies pinned (tests zero count array coercion)
552 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
553
554 Test-Path $jsonPath | Should -BeTrue
555 $result = Get-Content $jsonPath | ConvertFrom-Json
556 $result.UnpinnedDependencies | Should -Be 0
557 }
558 }
559}
560
561Describe 'Get-NpmDependencyViolations' -Tag 'Unit' {
562 BeforeAll {
563 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
564 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Npm'
565 }
566
567 Context 'Metadata-only package.json' {
568 It 'Returns zero violations for package with no dependencies' {
569 $fileInfo = @{
570 Path = Join-Path $script:FixturesPath 'metadata-only-package.json'
571 Type = 'npm'
572 RelativePath = 'metadata-only-package.json'
573 }
574
575 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
576
577 $violations.Count | Should -Be 0
578 }
579 }
580
581 Context 'Package.json with dependencies' {
582 It 'Detects unpinned dependencies in all sections' {
583 $fileInfo = @{
584 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
585 Type = 'npm'
586 RelativePath = 'with-dependencies-package.json'
587 }
588
589 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
590
591 $violations.Count | Should -BeGreaterThan 0
592 }
593
594 It 'Identifies correct dependency sections' {
595 $fileInfo = @{
596 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
597 Type = 'npm'
598 RelativePath = 'with-dependencies-package.json'
599 }
600
601 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
602 $sections = $violations | ForEach-Object { $_.Metadata.Section } | Sort-Object -Unique
603
604 $sections | Should -Contain 'dependencies'
605 $sections | Should -Contain 'devDependencies'
606 }
607
608 It 'Captures package name and version in violations' {
609 $fileInfo = @{
610 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
611 Type = 'npm'
612 RelativePath = 'with-dependencies-package.json'
613 }
614
615 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
616 $lodashViolation = $violations | Where-Object { $_.Name -eq 'lodash' }
617
618 $lodashViolation | Should -Not -BeNullOrEmpty
619 $lodashViolation.Name | Should -Be 'lodash'
620 $lodashViolation.Version | Should -Be '^4.17.21'
621 }
622 }
623
624 Context 'Non-existent file' {
625 It 'Returns empty array for missing file' {
626 $fileInfo = @{
627 Path = 'C:\nonexistent\package.json'
628 Type = 'npm'
629 RelativePath = 'nonexistent/package.json'
630 }
631
632 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
633
634 $violations.Count | Should -Be 0
635 }
636 }
637
638 Context 'When package.json contains invalid JSON' {
639 BeforeAll {
640 $script:invalidJsonPath = Join-Path $script:FixturesPath 'invalid-json-package.json'
641 }
642
643 It 'Returns empty violations array on parse failure' {
644 $fileInfo = @{
645 Path = $script:invalidJsonPath
646 Type = 'npm'
647 RelativePath = 'invalid-json-package.json'
648 }
649
650 $violations = @(Get-NpmDependencyViolations -FileInfo $fileInfo)
651
652 $violations | Should -HaveCount 0
653 }
654
655 It 'Emits a warning about parse failure' {
656 $fileInfo = @{
657 Path = $script:invalidJsonPath
658 Type = 'npm'
659 RelativePath = 'invalid-json-package.json'
660 }
661
662 $warnings = Get-NpmDependencyViolations -FileInfo $fileInfo 3>&1
663
664 $warnings | Should -Not -BeNullOrEmpty
665 $warnings | Should -Match 'Failed to parse.*as JSON'
666 }
667 }
668
669 Context 'When package.json contains empty or whitespace versions' {
670 BeforeAll {
671 $script:emptyVersionPath = Join-Path $script:FixturesPath 'empty-version-package.json'
672 }
673
674 It 'Skips dependencies with empty versions' {
675 $fileInfo = @{
676 Path = $script:emptyVersionPath
677 Type = 'npm'
678 RelativePath = 'empty-version-package.json'
679 }
680
681 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
682 $packageNames = $violations | ForEach-Object { $_.Name }
683
684 $packageNames | Should -Not -Contain 'empty-version'
685 $packageNames | Should -Not -Contain 'whitespace-version'
686 }
687
688 It 'Reports violations for valid non-pinned versions in same file' {
689 $fileInfo = @{
690 Path = $script:emptyVersionPath
691 Type = 'npm'
692 RelativePath = 'empty-version-package.json'
693 }
694
695 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
696
697 $violations.Count | Should -BeGreaterThan 0
698 $violations | Where-Object { $_.Name -eq 'valid-package' } | Should -Not -BeNullOrEmpty
699 }
700 }
701}
702
703Describe 'Get-RemediationSuggestion' -Tag 'Unit' {
704 Context 'Without -Remediate flag' {
705 It 'Returns enable-flag message' {
706 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
707 $v.Version = 'v4'
708 $result = Get-RemediationSuggestion -Violation $v
709 $result | Should -BeLike '*Enable -Remediate flag*'
710 }
711 }
712
713 Context 'GitHub Actions with -Remediate' {
714 It 'Resolves SHA from API and returns pin suggestion' {
715 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
716 $v.Version = 'v4'
717 $fakeSha = 'a'.PadRight(40, 'b')
718 Mock Invoke-RestMethod { return @{ sha = $fakeSha } }
719 $result = Get-RemediationSuggestion -Violation $v -Remediate
720 $result | Should -BeLike "Pin to SHA: uses: actions/checkout@$fakeSha*"
721 }
722
723 It 'Returns manual fallback when API throws' {
724 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
725 $v.Version = 'v4'
726 Mock Invoke-RestMethod { throw 'API error' }
727 Mock Write-PinningLog {}
728 $result = Get-RemediationSuggestion -Violation $v -Remediate
729 $result | Should -Be 'Manually research and pin to immutable reference'
730 }
731 }
732
733 Context 'Non-github-actions type with -Remediate' {
734 It 'Returns generic research message' {
735 $v = [DependencyViolation]::new('req.txt', 1, 'pip', 'requests', 'Medium', 'desc')
736 $v.Version = '2.31.0'
737 $result = Get-RemediationSuggestion -Violation $v -Remediate
738 $result | Should -BeLike '*Research and pin*pip*'
739 }
740 }
741}
742
743Describe 'Get-DependencyViolation with ValidationFunc' -Tag 'Unit' {
744 Context 'npm type triggers ValidationFunc path' {
745 BeforeAll {
746 $script:npmFixturePath = Join-Path $script:SecurityFixturesPath 'npm-violations'
747 if (-not (Test-Path $script:npmFixturePath)) {
748 New-Item -ItemType Directory -Path $script:npmFixturePath -Force | Out-Null
749 }
750 $script:pkgPath = Join-Path $script:npmFixturePath 'test-pkg.json'
751 Set-Content -Path $script:pkgPath -Value '{"dependencies":{"lodash":"^4.17.21"}}'
752 }
753
754 It 'Uses ValidationFunc instead of regex patterns' {
755 $fileInfo = @{
756 Path = $script:pkgPath
757 Type = 'npm'
758 RelativePath = 'test-pkg.json'
759 }
760 $violations = Get-DependencyViolation -FileInfo $fileInfo
761 $violations | Should -Not -BeNullOrEmpty
762 $violations[0].GetType().Name | Should -Be 'DependencyViolation'
763 }
764
765 It 'Sets File from FileInfo when missing' {
766 $fileInfo = @{
767 Path = $script:pkgPath
768 Type = 'npm'
769 RelativePath = 'test-pkg.json'
770 }
771 $violations = Get-DependencyViolation -FileInfo $fileInfo
772 $violations | ForEach-Object { $_.File | Should -Not -BeNullOrEmpty }
773 }
774 }
775}
776
777Describe 'Invoke-DependencyPinningAnalysis' -Tag 'Unit' {
778 BeforeAll {
779 Mock Get-FilesToScan { return @() }
780 Mock Get-ComplianceReportData {
781 return @{
782 ComplianceScore = 100.0
783 TotalDependencies = 0
784 UnpinnedDependencies = 0
785 Violations = @()
786 }
787 }
788 Mock Export-ComplianceReport {}
789 Mock Export-CICDArtifact {}
790 }
791
792 Context 'All dependencies pinned' {
793 It 'Logs success message without throwing' {
794 { Invoke-DependencyPinningAnalysis -Path TestDrive: } | Should -Not -Throw
795 }
796 }
797
798 Context 'Violations below threshold with -FailOnUnpinned' {
799 BeforeAll {
800 Mock Get-FilesToScan {
801 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
802 }
803 Mock Get-DependencyViolation {
804 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
805 return @($v)
806 }
807 Mock Get-RemediationSuggestion { return 'pin it' }
808 Mock Get-ComplianceReportData {
809 return @{
810 ComplianceScore = 50.0
811 TotalDependencies = 2
812 UnpinnedDependencies = 1
813 Violations = @()
814 }
815 }
816 }
817
818 It 'Throws when score below threshold and -FailOnUnpinned' {
819 { Invoke-DependencyPinningAnalysis -Path TestDrive: -FailOnUnpinned -Threshold 80 } | Should -Throw '*below threshold*'
820 }
821
822 It 'Does not throw in soft-fail mode' {
823 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
824 }
825 }
826
827 Context 'Score meets threshold' {
828 BeforeAll {
829 Mock Get-FilesToScan {
830 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
831 }
832 Mock Get-DependencyViolation {
833 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'Low', 'desc')
834 return @($v)
835 }
836 Mock Get-RemediationSuggestion { return 'pin it' }
837 Mock Get-ComplianceReportData {
838 return @{
839 ComplianceScore = 90.0
840 TotalDependencies = 10
841 UnpinnedDependencies = 1
842 Violations = @()
843 }
844 }
845 }
846
847 It 'Does not throw when score meets threshold' {
848 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
849 }
850 }
851}
852