microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/update-workflow-file-and-script

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

1173lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5BeforeAll {
6 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
7 # Re-import CIHelpers so Pester can resolve its commands for mocking;
8 # the nested-module import inside SecurityHelpers shadows the standalone copy.
9 Import-Module (Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1') -Force
10
11 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
12 Import-Module $mockPath -Force
13
14 # Fixture paths
15 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Workflows'
16 $script:SecurityFixturesPath = Join-Path $PSScriptRoot '../Fixtures/Security'
17
18 # CI helper mocks — suppress console output and enable assertions
19 Mock Write-Host {}
20 Mock Write-CIAnnotation {}
21 Mock Write-CIStepSummary {}
22 # Module-scoped mocks — intercept calls from within SecurityHelpers module
23 Mock Write-Host {} -ModuleName SecurityHelpers
24 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
25 Mock Write-CIStepSummary {} -ModuleName SecurityHelpers
26}
27
28Describe 'Test-SHAPinning' -Tag 'Unit' {
29 Context 'Valid SHA references for github-actions' {
30 It 'Returns true for valid 40-char lowercase SHA' {
31 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
32 }
33
34 It 'Returns true for valid 40-char mixed case SHA' {
35 Test-SHAPinning -Version 'A5AC7E51B41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeTrue
36 }
37 }
38
39 Context 'Invalid SHA references for github-actions' {
40 It 'Returns false for tag reference' {
41 Test-SHAPinning -Version 'v4' -Type 'github-actions' | Should -BeFalse
42 }
43
44 It 'Returns false for branch reference' {
45 Test-SHAPinning -Version 'main' -Type 'github-actions' | Should -BeFalse
46 }
47
48 It 'Returns false for 39-char reference' {
49 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc2' -Type 'github-actions' | Should -BeFalse
50 }
51
52 It 'Returns false for 41-char reference' {
53 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc291' -Type 'github-actions' | Should -BeFalse
54 }
55
56 It 'Returns false for non-hex characters' {
57 Test-SHAPinning -Version 'g5ac7e51b41094c92402da3b24376905380afc29' -Type 'github-actions' | Should -BeFalse
58 }
59 }
60
61 Context 'Unknown type' {
62 It 'Returns false for unknown dependency type' {
63 Test-SHAPinning -Version 'a5ac7e51b41094c92402da3b24376905380afc29' -Type 'unknown-type' | Should -BeFalse
64 }
65 }
66}
67
68Describe 'Test-ShellDownloadSecurity' -Tag 'Unit' {
69 Context 'Insecure downloads' {
70 It 'Detects curl without checksum verification' {
71 $testFile = Join-Path $script:SecurityFixturesPath 'insecure-download.sh'
72 $fileInfo = @{
73 Path = $testFile
74 Type = 'shell-downloads'
75 RelativePath = 'insecure-download.sh'
76 }
77 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
78 $result | Should -Not -BeNullOrEmpty
79 $result[0].Severity | Should -Be 'warning'
80 }
81 }
82
83 Context 'File not found' {
84 It 'Returns empty array for non-existent file' {
85 $fileInfo = @{
86 Path = 'TestDrive:/nonexistent/file.sh'
87 Type = 'shell-downloads'
88 RelativePath = 'nonexistent/file.sh'
89 }
90 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
91 $result | Should -BeNullOrEmpty
92 }
93 }
94}
95
96Describe 'Get-DependencyViolation' -Tag 'Unit' {
97 Context 'Pinned workflows' {
98 It 'Returns no violations for fully pinned workflow' {
99 $pinnedPath = Join-Path $script:FixturesPath 'pinned-workflow.yml'
100 $fileInfo = @{
101 Path = $pinnedPath
102 Type = 'github-actions'
103 RelativePath = 'pinned-workflow.yml'
104 }
105 $result = Get-DependencyViolation -FileInfo $fileInfo
106 $result | Should -BeNullOrEmpty
107 }
108 }
109
110 Context 'Unpinned workflows' {
111 It 'Detects unpinned action references' {
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 | Should -Not -BeNullOrEmpty
120 $result.Count | Should -BeGreaterThan 0
121 }
122
123 It 'Returns correct violation type for unpinned actions' {
124 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
125 $fileInfo = @{
126 Path = $unpinnedPath
127 Type = 'github-actions'
128 RelativePath = 'unpinned-workflow.yml'
129 }
130 $result = Get-DependencyViolation -FileInfo $fileInfo
131 $result[0].Type | Should -Be 'github-actions'
132 }
133 }
134
135 Context 'Mixed workflows' {
136 It 'Detects only unpinned actions in mixed workflow' {
137 $mixedPath = Join-Path $script:FixturesPath 'mixed-pinning-workflow.yml'
138 $fileInfo = @{
139 Path = $mixedPath
140 Type = 'github-actions'
141 RelativePath = 'mixed-pinning-workflow.yml'
142 }
143 $result = Get-DependencyViolation -FileInfo $fileInfo
144 $result | Should -Not -BeNullOrEmpty
145 # Should only detect the unpinned setup-node action
146 $result.Name | Should -Contain 'actions/setup-node'
147 }
148 }
149
150 Context 'Non-existent file' {
151 It 'Returns empty array for non-existent file' {
152 $fileInfo = @{
153 Path = 'TestDrive:/nonexistent/file.yml'
154 Type = 'github-actions'
155 RelativePath = 'file.yml'
156 }
157 $result = Get-DependencyViolation -FileInfo $fileInfo
158 $result | Should -BeNullOrEmpty
159 }
160 }
161}
162
163Describe 'Export-ComplianceReport' -Tag 'Unit' {
164 BeforeEach {
165 $script:TestOutputPath = Join-Path $TestDrive 'report'
166 New-Item -ItemType Directory -Path $script:TestOutputPath -Force | Out-Null
167
168 # Create a proper ComplianceReport class instance
169 $script:MockReport = [ComplianceReport]::new()
170 $script:MockReport.ScanPath = $script:FixturesPath
171 $script:MockReport.ComplianceScore = 50
172 $script:MockReport.TotalFiles = 3
173 $script:MockReport.ScannedFiles = 3
174 $script:MockReport.TotalDependencies = 4
175 $script:MockReport.PinnedDependencies = 2
176 $script:MockReport.UnpinnedDependencies = 2
177 $script:MockReport.Violations = @(
178 [PSCustomObject]@{
179 File = 'unpinned-workflow.yml'
180 Line = 10
181 Type = 'github-actions'
182 Name = 'actions/checkout'
183 Version = 'v4'
184 Severity = 'High'
185 Description = 'Unpinned dependency'
186 Remediation = 'Pin to SHA'
187 }
188 )
189 $script:MockReport.Summary = @{
190 'github-actions' = @{
191 Total = 4
192 High = 2
193 Medium = 0
194 Low = 0
195 }
196 }
197 }
198
199 Context 'JSON format' {
200 It 'Generates valid JSON report' {
201 $outputFile = Join-Path $script:TestOutputPath 'report.json'
202
203 Export-ComplianceReport -Report $script:MockReport -Format 'json' -OutputPath $outputFile
204
205 Test-Path $outputFile | Should -BeTrue
206 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
207 $content | Should -Not -BeNullOrEmpty
208 }
209 }
210
211 Context 'SARIF format' {
212 It 'Generates valid SARIF report' {
213 $outputFile = Join-Path $script:TestOutputPath 'report.sarif'
214
215 Export-ComplianceReport -Report $script:MockReport -Format 'sarif' -OutputPath $outputFile
216
217 Test-Path $outputFile | Should -BeTrue
218 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
219 $content.'$schema' | Should -Match 'sarif'
220 }
221 }
222
223 Context 'Table format' {
224 It 'Generates table output without error' {
225 $outputFile = Join-Path $script:TestOutputPath 'report.txt'
226
227 { Export-ComplianceReport -Report $script:MockReport -Format 'table' -OutputPath $outputFile } | Should -Not -Throw
228 Test-Path $outputFile | Should -BeTrue
229 }
230 }
231
232 Context 'CSV format' {
233 It 'Generates CSV report' {
234 $outputFile = Join-Path $script:TestOutputPath 'report.csv'
235
236 Export-ComplianceReport -Report $script:MockReport -Format 'csv' -OutputPath $outputFile
237
238 Test-Path $outputFile | Should -BeTrue
239 }
240 }
241
242 Context 'Markdown format' {
243 It 'Generates Markdown report' {
244 $outputFile = Join-Path $script:TestOutputPath 'report.md'
245
246 Export-ComplianceReport -Report $script:MockReport -Format 'markdown' -OutputPath $outputFile
247
248 Test-Path $outputFile | Should -BeTrue
249 $content = Get-Content $outputFile -Raw
250 $content | Should -Match '# Dependency Pinning Compliance Report'
251 }
252 }
253}
254
255Describe 'ExcludePaths Filtering Logic' -Tag 'Unit' {
256 Context 'Pattern matching with -notlike operator' {
257 It 'Excludes paths containing pattern using -notlike wildcard' {
258 # Test the exclusion logic used in Get-FilesToScan:
259 # $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
260 $testPaths = @(
261 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
262 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
263 )
264
265 $exclude = 'vendor'
266 $filtered = $testPaths | Where-Object { $_.FullName -notlike "*$exclude*" }
267
268 $filtered.Count | Should -Be 1
269 $filtered[0].FullName | Should -Not -Match 'vendor'
270 }
271
272 It 'Excludes multiple patterns correctly' {
273 $testPaths = @(
274 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
275 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
276 @{ FullName = 'C:\repo\node_modules\pkg\workflow.yml' }
277 )
278
279 $excludePatterns = @('vendor', 'node_modules')
280 $filtered = $testPaths
281 foreach ($exclude in $excludePatterns) {
282 $filtered = @($filtered | Where-Object { $_.FullName -notlike "*$exclude*" })
283 }
284
285 $filtered.Count | Should -Be 1
286 $filtered[0].FullName | Should -Be 'C:\repo\.github\workflows\test.yml'
287 }
288 }
289
290 Context 'Processes all files when ExcludePatterns is empty' {
291 It 'Returns all paths when no exclusion patterns provided' {
292 $testPaths = @(
293 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
294 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
295 )
296
297 $excludePatterns = @()
298 $filtered = $testPaths
299 if ($excludePatterns) {
300 foreach ($exclude in $excludePatterns) {
301 $filtered = $filtered | Where-Object { $_.FullName -notlike "*$exclude*" }
302 }
303 }
304
305 $filtered.Count | Should -Be 2
306 }
307 }
308
309 Context 'Comma-separated pattern parsing in main script' {
310 It 'Parses comma-separated exclude paths correctly' {
311 # Test the pattern used in main execution: $ExcludePaths.Split(',')
312 $excludePathsParam = 'vendor,node_modules,dist'
313 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
314
315 $patterns.Count | Should -Be 3
316 $patterns | Should -Contain 'vendor'
317 $patterns | Should -Contain 'node_modules'
318 $patterns | Should -Contain 'dist'
319 }
320
321 It 'Handles single pattern without comma' {
322 $excludePathsParam = 'vendor'
323 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
324
325 $patterns.Count | Should -Be 1
326 $patterns | Should -Contain 'vendor'
327 }
328
329 It 'Handles empty exclude paths' {
330 $excludePathsParam = ''
331 $patterns = if ($excludePathsParam) { $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
332
333 $patterns.Count | Should -Be 0
334 }
335 }
336
337 Context 'Pattern matching behavior' {
338 It 'Uses -notlike with wildcard for exclusion' {
339 $filePath = 'C:\repo\vendor\.github\workflows\test.yml'
340 $pattern = 'vendor'
341
342 # This matches how Get-FilesToScan uses: $_.FullName -notlike "*$exclude*"
343 $filePath -notlike "*$pattern*" | Should -BeFalse
344 }
345
346 It 'Passes through non-matching paths' {
347 $filePath = 'C:\repo\.github\workflows\main.yml'
348 $pattern = 'vendor'
349
350 $filePath -notlike "*$pattern*" | Should -BeTrue
351 }
352 }
353}
354
355Describe 'Dot-sourced execution protection' -Tag 'Unit' {
356 Context 'When script is dot-sourced' {
357 It 'Does not execute main block when dot-sourced' {
358 # Arrange
359 $testScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
360 $tempOutputPath = Join-Path $TestDrive 'dot-source-test.json'
361
362 # Act - Invoke in new process with dot-sourcing simulation
363 $scriptBlock = ". '$testScript' -OutputPath '$tempOutputPath'; [System.IO.File]::Exists('$tempOutputPath')"
364 pwsh -Command $scriptBlock 2>&1 | Out-Null
365
366 # Assert - Main execution should be skipped, no output file created
367 Test-Path $tempOutputPath | Should -BeFalse
368 }
369
370 }
371}
372
373Describe 'GitHub Actions error annotation' {
374 BeforeAll {
375 $script:OriginalGHA = $env:GITHUB_ACTIONS
376 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
377 }
378
379 AfterAll {
380 if ($null -eq $script:OriginalGHA) {
381 Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue
382 } else {
383 $env:GITHUB_ACTIONS = $script:OriginalGHA
384 }
385 }
386
387 Context 'Error handling with GitHub Actions' {
388 It 'Outputs GitHub error annotation on failure' {
389 # Arrange - Create a corrupted workflow file that will trigger an error
390 $testWorkflowDir = Join-Path $TestDrive 'test-workflows'
391 New-Item -ItemType Directory -Path (Join-Path $testWorkflowDir '.github/workflows') -Force | Out-Null
392 $corruptedFile = Join-Path $testWorkflowDir '.github/workflows/test.yml'
393 "uses: actions/checkout@invalid!!!" | Out-File -FilePath $corruptedFile -Encoding UTF8
394
395 # Act - Run script in new process with GITHUB_ACTIONS set
396 $scriptCommand = @"
397`$env:GITHUB_ACTIONS = 'true'
398& '$script:TestScript' -Path '$testWorkflowDir' -Format 'json' -OutputPath '$TestDrive/gha-test.json' -FailOnUnpinned 2>&1
399"@
400 $output = pwsh -Command $scriptCommand
401
402 # Assert - Should contain GitHub Actions error annotation or error output
403 # The script should execute and potentially generate warnings/errors
404 $output | Should -Not -BeNullOrEmpty
405 }
406 }
407}
408
409Describe 'Get-ComplianceReportData' -Tag 'Unit' {
410 BeforeAll {
411 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
412 }
413
414 Context 'Array coercion operations' {
415 It 'Handles empty violations array' {
416 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations @() -ScannedFiles @()
417
418 $result.TotalDependencies | Should -Be 0
419 $result.UnpinnedDependencies | Should -Be 0
420 $result.PinnedDependencies | Should -Be 0
421 $result.ComplianceScore | Should -Be 100.0
422 }
423
424 It 'Counts violations correctly with array coercion' {
425 $v1 = [DependencyViolation]::new()
426 $v1.Type = 'github-actions'
427 $v1.Severity = 'High'
428
429 $v2 = [DependencyViolation]::new()
430 $v2.Type = 'github-actions'
431 $v2.Severity = 'Medium'
432
433 $v3 = [DependencyViolation]::new()
434 $v3.Type = 'npm'
435 $v3.Severity = 'High'
436
437 $violations = @($v1, $v2, $v3)
438 $scannedFiles = @(@{ Path = 'test1.yml' }, @{ Path = 'test2.json' })
439
440 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
441
442 $result.TotalDependencies | Should -Be 3
443 $result.UnpinnedDependencies | Should -Be 3
444 }
445
446 It 'Groups violations by type with array coercion' {
447 $v1 = [DependencyViolation]::new()
448 $v1.Type = 'github-actions'
449 $v1.Severity = 'High'
450
451 $v2 = [DependencyViolation]::new()
452 $v2.Type = 'github-actions'
453 $v2.Severity = 'Low'
454
455 $v3 = [DependencyViolation]::new()
456 $v3.Type = 'npm'
457 $v3.Severity = 'Medium'
458
459 $violations = @($v1, $v2, $v3)
460 $scannedFiles = @(@{ Path = 'test.yml' })
461
462 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
463
464 $result.Summary.Keys | Should -Contain 'github-actions'
465 $result.Summary.Keys | Should -Contain 'npm'
466 $result.Summary['github-actions'].Total | Should -Be 2
467 $result.Summary['npm'].Total | Should -Be 1
468 }
469
470 It 'Counts severity levels correctly with array coercion' {
471 $violations = @()
472 for ($i = 0; $i -lt 4; $i++) {
473 $v = [DependencyViolation]::new()
474 $v.Type = 'github-actions'
475 $v.Severity = switch ($i) {
476 0 { 'High' }
477 1 { 'High' }
478 2 { 'Medium' }
479 3 { 'Low' }
480 }
481 $violations += $v
482 }
483 $scannedFiles = @(@{ Path = 'test.yml' })
484
485 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
486
487 $result.Summary['github-actions'].High | Should -Be 2
488 $result.Summary['github-actions'].Medium | Should -Be 1
489 $result.Summary['github-actions'].Low | Should -Be 1
490 }
491
492 It 'Handles single violation without PowerShell unrolling' {
493 $v = [DependencyViolation]::new()
494 $v.Type = 'github-actions'
495 $v.Severity = 'High'
496
497 $violations = @($v)
498 $scannedFiles = @(@{ Path = 'test.yml' })
499
500 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
501
502 $result.TotalDependencies | Should -Be 1
503 $result.Summary['github-actions'].Total | Should -Be 1
504 $result.Summary['github-actions'].High | Should -Be 1
505 }
506 }
507}
508
509Describe 'Main Script Execution' {
510 BeforeAll {
511 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
512 $script:TestWorkspaceDir = Join-Path $TestDrive 'test-workspace'
513 New-Item -ItemType Directory -Path $script:TestWorkspaceDir -Force | Out-Null
514
515 # Create .github/workflows directory
516 $workflowDir = Join-Path $script:TestWorkspaceDir '.github/workflows'
517 New-Item -ItemType Directory -Path $workflowDir -Force | Out-Null
518 }
519
520 Context 'Array coercion in main execution block' {
521 It 'Executes array coercion when scanning files' {
522 # Create test workflow file
523 $workflowContent = @'
524name: Test
525on: push
526jobs:
527 test:
528 runs-on: ubuntu-latest
529 steps:
530 - uses: actions/checkout@v4
531'@
532 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/test.yml') -Value $workflowContent
533
534 $jsonPath = Join-Path $TestDrive 'scan-output.json'
535
536 # Execute script with array coercion operations
537 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
538
539 # Verify output was created (proves array operations executed)
540 Test-Path $jsonPath | Should -BeTrue
541 $result = Get-Content $jsonPath | ConvertFrom-Json
542 $result.PSObject.Properties.Name | Should -Contain 'ComplianceScore'
543 }
544
545 It 'Handles empty scan results with array coercion' {
546 # Remove workflow files
547 Remove-Item -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/*.yml') -Force -ErrorAction SilentlyContinue
548
549 # Create pinned workflow
550 $pinnedContent = @'
551name: Pinned
552on: push
553jobs:
554 test:
555 runs-on: ubuntu-latest
556 steps:
557 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
558'@
559 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/pinned.yml') -Value $pinnedContent
560
561 $jsonPath = Join-Path $TestDrive 'empty-output.json'
562
563 # Execute with all dependencies pinned (tests zero count array coercion)
564 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
565
566 Test-Path $jsonPath | Should -BeTrue
567 $result = Get-Content $jsonPath | ConvertFrom-Json
568 $result.UnpinnedDependencies | Should -Be 0
569 }
570 }
571}
572
573Describe 'Get-NpmDependencyViolations' -Tag 'Unit' {
574 BeforeAll {
575 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
576 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Npm'
577 }
578
579 Context 'Metadata-only package.json' {
580 It 'Returns zero violations for package with no dependencies' {
581 $fileInfo = @{
582 Path = Join-Path $script:FixturesPath 'metadata-only-package.json'
583 Type = 'npm'
584 RelativePath = 'metadata-only-package.json'
585 }
586
587 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
588
589 $violations.Count | Should -Be 0
590 }
591 }
592
593 Context 'Package.json with dependencies' {
594 It 'Detects unpinned dependencies in all 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
603 $violations.Count | Should -BeGreaterThan 0
604 }
605
606 It 'Identifies correct dependency sections' {
607 $fileInfo = @{
608 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
609 Type = 'npm'
610 RelativePath = 'with-dependencies-package.json'
611 }
612
613 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
614 $sections = $violations | ForEach-Object { $_.Metadata.Section } | Sort-Object -Unique
615
616 $sections | Should -Contain 'dependencies'
617 $sections | Should -Contain 'devDependencies'
618 }
619
620 It 'Captures package name and version in violations' {
621 $fileInfo = @{
622 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
623 Type = 'npm'
624 RelativePath = 'with-dependencies-package.json'
625 }
626
627 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
628 $lodashViolation = $violations | Where-Object { $_.Name -eq 'lodash' }
629
630 $lodashViolation | Should -Not -BeNullOrEmpty
631 $lodashViolation.Name | Should -Be 'lodash'
632 $lodashViolation.Version | Should -Be '^4.17.21'
633 }
634 }
635
636 Context 'Non-existent file' {
637 It 'Returns empty array for missing file' {
638 $fileInfo = @{
639 Path = 'C:\nonexistent\package.json'
640 Type = 'npm'
641 RelativePath = 'nonexistent/package.json'
642 }
643
644 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
645
646 $violations.Count | Should -Be 0
647 }
648 }
649
650 Context 'When package.json contains invalid JSON' {
651 BeforeAll {
652 $script:invalidJsonPath = Join-Path $script:FixturesPath 'invalid-json-package.json'
653 }
654
655 It 'Returns empty violations array on parse failure' {
656 $fileInfo = @{
657 Path = $script:invalidJsonPath
658 Type = 'npm'
659 RelativePath = 'invalid-json-package.json'
660 }
661
662 $violations = @(Get-NpmDependencyViolations -FileInfo $fileInfo)
663
664 $violations | Should -HaveCount 0
665 }
666
667 It 'Emits a warning about parse failure' {
668 $fileInfo = @{
669 Path = $script:invalidJsonPath
670 Type = 'npm'
671 RelativePath = 'invalid-json-package.json'
672 }
673
674 $warnings = Get-NpmDependencyViolations -FileInfo $fileInfo 3>&1
675
676 $warnings | Should -Not -BeNullOrEmpty
677 $warnings | Should -Match 'Failed to parse.*as JSON'
678 }
679 }
680
681 Context 'When package.json contains empty or whitespace versions' {
682 BeforeAll {
683 $script:emptyVersionPath = Join-Path $script:FixturesPath 'empty-version-package.json'
684 }
685
686 It 'Skips dependencies with empty versions' {
687 $fileInfo = @{
688 Path = $script:emptyVersionPath
689 Type = 'npm'
690 RelativePath = 'empty-version-package.json'
691 }
692
693 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
694 $packageNames = $violations | ForEach-Object { $_.Name }
695
696 $packageNames | Should -Not -Contain 'empty-version'
697 $packageNames | Should -Not -Contain 'whitespace-version'
698 }
699
700 It 'Reports violations for valid non-pinned versions in same file' {
701 $fileInfo = @{
702 Path = $script:emptyVersionPath
703 Type = 'npm'
704 RelativePath = 'empty-version-package.json'
705 }
706
707 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
708
709 $violations.Count | Should -BeGreaterThan 0
710 $violations | Where-Object { $_.Name -eq 'valid-package' } | Should -Not -BeNullOrEmpty
711 }
712 }
713}
714
715Describe 'Get-RemediationSuggestion' -Tag 'Unit' {
716 Context 'Without -Remediate flag' {
717 It 'Returns enable-flag message' {
718 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
719 $v.Version = 'v4'
720 $result = Get-RemediationSuggestion -Violation $v
721 $result | Should -BeLike '*Enable -Remediate flag*'
722 }
723 }
724
725 Context 'GitHub Actions with -Remediate' {
726 It 'Resolves SHA from API and returns pin suggestion' {
727 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
728 $v.Version = 'v4'
729 $fakeSha = 'a'.PadRight(40, 'b')
730 Mock Invoke-RestMethod { return @{ sha = $fakeSha } }
731 $result = Get-RemediationSuggestion -Violation $v -Remediate
732 $result | Should -BeLike "Pin to SHA: uses: actions/checkout@$fakeSha*"
733 }
734
735 It 'Returns manual fallback when API throws' {
736 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
737 $v.Version = 'v4'
738 Mock Invoke-RestMethod { throw 'API error' }
739 Mock Write-SecurityLog {}
740 $result = Get-RemediationSuggestion -Violation $v -Remediate
741 $result | Should -Be 'Manually research and pin to immutable reference'
742 }
743 }
744
745 Context 'Non-github-actions type with -Remediate' {
746 It 'Returns generic research message' {
747 $v = [DependencyViolation]::new('req.txt', 1, 'pip', 'requests', 'Medium', 'desc')
748 $v.Version = '2.31.0'
749 $result = Get-RemediationSuggestion -Violation $v -Remediate
750 $result | Should -BeLike '*Research and pin*pip*'
751 }
752 }
753}
754
755Describe 'Get-DependencyViolation with ValidationFunc' -Tag 'Unit' {
756 Context 'npm type triggers ValidationFunc path' {
757 BeforeAll {
758 $script:npmFixturePath = Join-Path $script:SecurityFixturesPath 'npm-violations'
759 if (-not (Test-Path $script:npmFixturePath)) {
760 New-Item -ItemType Directory -Path $script:npmFixturePath -Force | Out-Null
761 }
762 $script:pkgPath = Join-Path $script:npmFixturePath 'test-pkg.json'
763 Set-Content -Path $script:pkgPath -Value '{"dependencies":{"lodash":"^4.17.21"}}'
764 }
765
766 It 'Uses ValidationFunc instead of regex patterns' {
767 $fileInfo = @{
768 Path = $script:pkgPath
769 Type = 'npm'
770 RelativePath = 'test-pkg.json'
771 }
772 $violations = Get-DependencyViolation -FileInfo $fileInfo
773 $violations | Should -Not -BeNullOrEmpty
774 $violations[0].GetType().Name | Should -Be 'DependencyViolation'
775 }
776
777 It 'Sets File from FileInfo when missing' {
778 $fileInfo = @{
779 Path = $script:pkgPath
780 Type = 'npm'
781 RelativePath = 'test-pkg.json'
782 }
783 $violations = Get-DependencyViolation -FileInfo $fileInfo
784 $violations | ForEach-Object { $_.File | Should -Not -BeNullOrEmpty }
785 }
786 }
787}
788
789Describe 'Invoke-DependencyPinningAnalysis' -Tag 'Unit' {
790 BeforeAll {
791 Mock Get-FilesToScan { return @() }
792 Mock Get-ComplianceReportData {
793 return @{
794 ComplianceScore = 100.0
795 TotalDependencies = 0
796 UnpinnedDependencies = 0
797 Violations = @()
798 }
799 }
800 Mock Export-ComplianceReport {}
801 Mock Export-CICDArtifact {}
802 }
803
804 Context 'All dependencies pinned' {
805 It 'Logs success message without throwing' {
806 { Invoke-DependencyPinningAnalysis -Path TestDrive: } | Should -Not -Throw
807 }
808
809 It 'emits success Write-Host message when no violations' {
810 Invoke-DependencyPinningAnalysis -Path TestDrive:
811 Should -Invoke Write-Host -ParameterFilter {
812 $Object -like '*✅*' -and $Object -like '*SHA-pinned*'
813 }
814 }
815
816 It 'does not emit Write-CIAnnotation warnings when no violations' {
817 Invoke-DependencyPinningAnalysis -Path TestDrive:
818 Should -Not -Invoke Write-CIAnnotation -ParameterFilter {
819 $Level -eq 'Warning'
820 }
821 }
822 }
823
824 Context 'Violations below threshold with -FailOnUnpinned' {
825 BeforeAll {
826 Mock Get-FilesToScan {
827 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
828 }
829 Mock Get-DependencyViolation {
830 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
831 return @($v)
832 }
833 Mock Get-RemediationSuggestion { return 'pin it' }
834 Mock Get-ComplianceReportData {
835 return @{
836 ComplianceScore = 50.0
837 TotalDependencies = 2
838 UnpinnedDependencies = 1
839 Violations = @()
840 }
841 }
842 }
843
844 It 'Throws when score below threshold and -FailOnUnpinned' {
845 { Invoke-DependencyPinningAnalysis -Path TestDrive: -FailOnUnpinned -Threshold 80 } | Should -Throw '*below threshold*'
846 }
847
848 It 'Does not throw in soft-fail mode' {
849 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
850 }
851 }
852
853 Context 'CI output for violations in soft-fail mode' {
854 BeforeAll {
855 Mock Get-FilesToScan {
856 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
857 }
858 Mock Get-DependencyViolation {
859 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
860 $v.CurrentRef = 'v4'
861 return @($v)
862 }
863 Mock Get-RemediationSuggestion { return 'pin it' }
864 Mock Get-ComplianceReportData {
865 return @{
866 ComplianceScore = 50.0
867 TotalDependencies = 2
868 UnpinnedDependencies = 1
869 Violations = @()
870 }
871 }
872 Mock Export-ComplianceReport {}
873 Mock Export-CICDArtifact {}
874 }
875
876 It 'emits summary header with violation count' {
877 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
878 Should -Invoke Write-Host -ParameterFilter {
879 $Object -like '*unpinned*'
880 }
881 }
882
883 It 'emits file header with file icon' {
884 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
885 Should -Invoke Write-Host -ParameterFilter {
886 $Object -like '*📄*'
887 }
888 }
889
890 It 'emits per-violation detail line' {
891 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
892 Should -Invoke Write-Host -ParameterFilter {
893 $Object -like '*❌*' -and $Object -like '*a/b*'
894 }
895 }
896
897 It 'emits Write-CIAnnotation with Error level for High severity violation' {
898 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
899 Should -Invoke Write-CIAnnotation -ParameterFilter {
900 $Level -eq 'Error' -and $File -eq 'f.yml' -and $Line -eq 1
901 }
902 }
903 }
904
905 Context 'Score meets threshold' {
906 BeforeAll {
907 Mock Get-FilesToScan {
908 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
909 }
910 Mock Get-DependencyViolation {
911 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'Low', 'desc')
912 return @($v)
913 }
914 Mock Get-RemediationSuggestion { return 'pin it' }
915 Mock Get-ComplianceReportData {
916 return @{
917 ComplianceScore = 90.0
918 TotalDependencies = 10
919 UnpinnedDependencies = 1
920 Violations = @()
921 }
922 }
923 }
924
925 It 'Does not throw when score meets threshold' {
926 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
927 }
928 }
929
930 Context 'CI annotations per violation' {
931 BeforeAll {
932 Mock Write-CIAnnotation {}
933 Mock Write-Host {}
934 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
935 Mock Write-Host {} -ModuleName SecurityHelpers
936 }
937
938 It 'Emits Write-CIAnnotation per violation' {
939 Mock Get-FilesToScan {
940 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
941 }
942 Mock Get-DependencyViolation {
943 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
944 $v.ViolationType = 'Unpinned'
945 $v.Version = 'v4'
946 return @($v)
947 }
948 Mock Get-RemediationSuggestion { return 'pin it' }
949 Mock Get-ComplianceReportData {
950 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
951 }
952
953 Invoke-DependencyPinningAnalysis -Path TestDrive:
954
955 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Error' -and $File -eq 'f.yml' -and $Line -eq 1 } -Times 1 -Exactly
956 }
957
958 It 'Maps High severity to Error level' {
959 Mock Get-FilesToScan {
960 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
961 }
962 Mock Get-DependencyViolation {
963 $v = [DependencyViolation]::new('f.yml', 5, 'github-actions', 'actions/checkout', 'High', 'Unpinned action')
964 $v.ViolationType = 'Unpinned'
965 $v.Version = 'v4'
966 return @($v)
967 }
968 Mock Get-RemediationSuggestion { return 'pin it' }
969 Mock Get-ComplianceReportData {
970 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
971 }
972
973 Invoke-DependencyPinningAnalysis -Path TestDrive:
974
975 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Error' -and $File -eq 'f.yml' } -Times 1 -Exactly
976 }
977
978 It 'Maps Medium severity to Warning level' {
979 Mock Get-FilesToScan {
980 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
981 }
982 Mock Get-DependencyViolation {
983 $v = [DependencyViolation]::new('f.yml', 3, 'npm', 'lodash', 'Medium', 'Unpinned npm dep')
984 $v.ViolationType = 'Unpinned'
985 $v.Version = '^4.0.0'
986 return @($v)
987 }
988 Mock Get-RemediationSuggestion { return 'pin it' }
989 Mock Get-ComplianceReportData {
990 return @{ ComplianceScore = 80.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
991 }
992
993 Invoke-DependencyPinningAnalysis -Path TestDrive:
994
995 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Warning' -and $File -eq 'f.yml' } -Times 1 -Exactly
996 }
997
998 It 'Maps Low severity to Notice level' {
999 Mock Get-FilesToScan {
1000 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1001 }
1002 Mock Get-DependencyViolation {
1003 $v = [DependencyViolation]::new('f.yml', 7, 'github-actions', 'a/b', 'Low', 'Minor issue')
1004 $v.ViolationType = 'MissingVersionComment'
1005 $v.Version = 'abc123'
1006 return @($v)
1007 }
1008 Mock Get-RemediationSuggestion { return 'add comment' }
1009 Mock Get-ComplianceReportData {
1010 return @{ ComplianceScore = 90.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1011 }
1012
1013 Invoke-DependencyPinningAnalysis -Path TestDrive:
1014
1015 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Notice' } -Times 1 -Exactly
1016 }
1017
1018 It 'Includes violation type in annotation message' {
1019 Mock Get-FilesToScan {
1020 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1021 }
1022 Mock Get-DependencyViolation {
1023 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1024 $v.ViolationType = 'Unpinned'
1025 $v.Version = 'v4'
1026 return @($v)
1027 }
1028 Mock Get-RemediationSuggestion { return 'pin it' }
1029 Mock Get-ComplianceReportData {
1030 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1031 }
1032
1033 Invoke-DependencyPinningAnalysis -Path TestDrive:
1034
1035 Should -Invoke Write-CIAnnotation -ParameterFilter { $Message -match 'Unpinned' }
1036 }
1037
1038 It 'Emits no annotations when no violations' {
1039 Mock Get-FilesToScan { return @() }
1040 Mock Get-ComplianceReportData {
1041 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1042 }
1043
1044 Invoke-DependencyPinningAnalysis -Path TestDrive:
1045
1046 Should -Invoke Write-CIAnnotation -Times 0
1047 }
1048
1049 It 'Emits multiple annotations for multiple violations' {
1050 Mock Get-FilesToScan {
1051 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1052 }
1053 Mock Get-DependencyViolation {
1054 $v1 = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1055 $v1.ViolationType = 'Unpinned'
1056 $v1.Version = 'v4'
1057 $v2 = [DependencyViolation]::new('f.yml', 5, 'github-actions', 'c/d', 'Medium', 'Also not pinned')
1058 $v2.ViolationType = 'Unpinned'
1059 $v2.Version = 'v3'
1060 return @($v1, $v2)
1061 }
1062 Mock Get-RemediationSuggestion { return 'pin it' }
1063 Mock Get-ComplianceReportData {
1064 return @{ ComplianceScore = 50.0; TotalDependencies = 2; UnpinnedDependencies = 2; Violations = @() }
1065 }
1066
1067 Invoke-DependencyPinningAnalysis -Path TestDrive:
1068
1069 Should -Invoke Write-CIAnnotation -ParameterFilter { $null -ne $File } -Times 2 -Exactly
1070 }
1071 }
1072
1073 Context 'Write-SecurityLog CI annotation forwarding' {
1074 BeforeAll {
1075 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1076 Mock Write-Host {} -ModuleName SecurityHelpers
1077 }
1078
1079 It 'Forwards Warning-level log messages as CI Warning annotations' {
1080 Mock Get-FilesToScan {
1081 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1082 }
1083 Mock Get-DependencyViolation {
1084 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1085 $v.ViolationType = 'Unpinned'
1086 $v.Version = 'v4'
1087 return @($v)
1088 }
1089 Mock Get-RemediationSuggestion { return 'pin it' }
1090 Mock Get-ComplianceReportData {
1091 return @{ ComplianceScore = 90.0; TotalDependencies = 2; UnpinnedDependencies = 1; Violations = @() }
1092 }
1093
1094 Invoke-DependencyPinningAnalysis -Path TestDrive:
1095
1096 # Write-SecurityLog -CIAnnotation "N dependencies require SHA pinning..." emits a Warning annotation
1097 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Warning' -and $null -eq $File -and $Message -match 'SHA pinning' }
1098 }
1099
1100 It 'Forwards Error-level log messages as CI Error annotations' {
1101 Mock Get-FilesToScan {
1102 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1103 }
1104 Mock Get-DependencyViolation {
1105 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1106 $v.ViolationType = 'Unpinned'
1107 $v.Version = 'v4'
1108 return @($v)
1109 }
1110 Mock Get-RemediationSuggestion { return 'pin it' }
1111 Mock Get-ComplianceReportData {
1112 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1113 }
1114
1115 Invoke-DependencyPinningAnalysis -Path TestDrive:
1116
1117 # Write-SecurityLog -CIAnnotation "Compliance score ... below threshold" emits an Error annotation
1118 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Error' -and $null -eq $File -and $Message -match 'below threshold' }
1119 }
1120
1121 It 'Does not forward Info-level log messages as annotations' {
1122 Mock Get-FilesToScan { return @() }
1123 Mock Get-ComplianceReportData {
1124 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1125 }
1126
1127 Invoke-DependencyPinningAnalysis -Path TestDrive:
1128
1129 # Info and Success levels should not produce CI annotations
1130 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $null -eq $File } -Times 0
1131 }
1132 }
1133
1134 Context 'Per-violation console output' {
1135 BeforeAll {
1136 Mock Write-CIAnnotation {}
1137 Mock Write-Host {}
1138 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1139 Mock Write-Host {} -ModuleName SecurityHelpers
1140 }
1141
1142 It 'Writes colored output for High severity violations' {
1143 Mock Get-FilesToScan {
1144 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1145 }
1146 Mock Get-DependencyViolation {
1147 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1148 $v.ViolationType = 'Unpinned'
1149 $v.Version = 'v4'
1150 return @($v)
1151 }
1152 Mock Get-RemediationSuggestion { return 'pin it' }
1153 Mock Get-ComplianceReportData {
1154 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1155 }
1156
1157 Invoke-DependencyPinningAnalysis -Path TestDrive:
1158
1159 Should -Invoke Write-Host -ParameterFilter { $ForegroundColor -eq 'Red' -and $Object -match 'a/b' }
1160 }
1161
1162 It 'Writes success message when no violations' {
1163 Mock Get-FilesToScan { return @() }
1164 Mock Get-ComplianceReportData {
1165 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1166 }
1167
1168 Invoke-DependencyPinningAnalysis -Path TestDrive:
1169
1170 Should -Invoke Write-Host -ParameterFilter { $ForegroundColor -eq 'Green' -and $Object -match 'SHA-pinned' }
1171 }
1172 }
1173}
1174