microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/skill-validator-python-support

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

1638lines · 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-NpmExactVersion' -Tag 'Unit' {
69 Context 'Exact versions' {
70 It 'Returns true for simple semver' {
71 Test-NpmExactVersion -Version '1.2.3' | Should -BeTrue
72 }
73
74 It 'Returns true for semver with prerelease tag' {
75 Test-NpmExactVersion -Version '1.0.0-beta.1' | Should -BeTrue
76 }
77
78 It 'Returns true for semver with build metadata' {
79 Test-NpmExactVersion -Version '2.0.0+build.42' | Should -BeTrue
80 }
81 }
82
83 Context 'Range specifiers' {
84 It 'Returns false for caret range' {
85 Test-NpmExactVersion -Version '^4.17.21' | Should -BeFalse
86 }
87
88 It 'Returns false for tilde range' {
89 Test-NpmExactVersion -Version '~4.18.2' | Should -BeFalse
90 }
91
92 It 'Returns false for wildcard' {
93 Test-NpmExactVersion -Version '*' | Should -BeFalse
94 }
95
96 It 'Returns false for greater-than-or-equal range' {
97 Test-NpmExactVersion -Version '>=17.0.0' | Should -BeFalse
98 }
99
100 It 'Returns false for URL dependency' {
101 Test-NpmExactVersion -Version 'https://example.com/pkg.tgz' | Should -BeFalse
102 }
103
104 It 'Returns false for git dependency' {
105 Test-NpmExactVersion -Version 'git+ssh://git@github.com/user/repo.git' | Should -BeFalse
106 }
107
108 It 'Returns false for dist-tag like latest' {
109 Test-NpmExactVersion -Version 'latest' | Should -BeFalse
110 }
111 }
112}
113
114Describe 'Test-ShellDownloadSecurity' -Tag 'Unit' {
115 Context 'Insecure downloads' {
116 It 'Detects curl without checksum verification' {
117 $testFile = Join-Path $script:SecurityFixturesPath 'insecure-download.sh'
118 $fileInfo = @{
119 Path = $testFile
120 Type = 'shell-downloads'
121 RelativePath = 'insecure-download.sh'
122 }
123 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
124 $result | Should -Not -BeNullOrEmpty
125 $result[0].Severity | Should -Be 'Medium'
126 }
127
128 It 'Detects both curl and wget violations in the same file' {
129 $testFile = Join-Path $script:SecurityFixturesPath 'insecure-download.sh'
130 $fileInfo = @{
131 Path = $testFile
132 Type = 'shell-downloads'
133 RelativePath = 'insecure-download.sh'
134 }
135 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
136 $result | Should -HaveCount 2
137 }
138
139 It 'Populates violation object fields correctly' {
140 $testFile = Join-Path $script:SecurityFixturesPath 'insecure-download.sh'
141 $fileInfo = @{
142 Path = $testFile
143 Type = 'shell-downloads'
144 RelativePath = 'insecure-download.sh'
145 }
146 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
147 $result[0].File | Should -Be 'insecure-download.sh'
148 $result[0].Type | Should -Be 'shell-downloads'
149 $result[0].Line | Should -BeGreaterThan 0
150 $result[0].Description | Should -Be 'Download without checksum verification'
151 $result[0].Name | Should -Match 'curl.*https://'
152 $result[0].Severity | Should -Be 'Medium'
153 $result[0].ViolationType | Should -Be 'Unpinned'
154 }
155
156 It 'Detects insecure download when checksum is beyond lookahead window' {
157 $scriptPath = Join-Path $TestDrive 'beyond-lookahead.sh'
158 # Download at line 1, checksum at line 8 (beyond 6-line window)
159 $content = @(
160 'curl -o /tmp/tool.tar.gz https://example.com/tool.tar.gz'
161 'echo "line 2"'
162 'echo "line 3"'
163 'echo "line 4"'
164 'echo "line 5"'
165 'echo "line 6"'
166 'echo "line 7"'
167 'sha256sum -c /tmp/tool.tar.gz.sha256'
168 )
169 Set-Content -Path $scriptPath -Value $content
170 $fileInfo = @{
171 Path = $scriptPath
172 Type = 'shell-downloads'
173 RelativePath = 'beyond-lookahead.sh'
174 }
175 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
176 $result | Should -HaveCount 1
177 }
178 }
179
180 Context 'Secure downloads' {
181 It 'Returns no violations for downloads with checksum verification' {
182 $testFile = Join-Path $script:SecurityFixturesPath 'secure-download.sh'
183 $fileInfo = @{
184 Path = $testFile
185 Type = 'shell-downloads'
186 RelativePath = 'secure-download.sh'
187 }
188 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
189 $result | Should -HaveCount 0
190 }
191
192 It 'Accepts sha256sum within lookahead window' {
193 $scriptPath = Join-Path $TestDrive 'sha256sum-check.sh'
194 Set-Content -Path $scriptPath -Value @(
195 'curl -o /tmp/tool.tar.gz https://example.com/tool.tar.gz'
196 'sha256sum -c /tmp/tool.tar.gz.sha256'
197 )
198 $fileInfo = @{
199 Path = $scriptPath
200 Type = 'shell-downloads'
201 RelativePath = 'sha256sum-check.sh'
202 }
203 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
204 $result | Should -HaveCount 0
205 }
206
207 It 'Accepts shasum within lookahead window' {
208 $scriptPath = Join-Path $TestDrive 'shasum-check.sh'
209 Set-Content -Path $scriptPath -Value @(
210 'wget https://example.com/tool.tar.gz -O /tmp/tool.tar.gz'
211 'shasum -a 256 /tmp/tool.tar.gz'
212 )
213 $fileInfo = @{
214 Path = $scriptPath
215 Type = 'shell-downloads'
216 RelativePath = 'shasum-check.sh'
217 }
218 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
219 $result | Should -HaveCount 0
220 }
221
222 It 'Accepts Get-FileHash within lookahead window' {
223 $scriptPath = Join-Path $TestDrive 'get-filehash-check.sh'
224 Set-Content -Path $scriptPath -Value @(
225 'curl -o /tmp/tool.tar.gz https://example.com/tool.tar.gz'
226 'Get-FileHash /tmp/tool.tar.gz'
227 )
228 $fileInfo = @{
229 Path = $scriptPath
230 Type = 'shell-downloads'
231 RelativePath = 'get-filehash-check.sh'
232 }
233 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
234 $result | Should -HaveCount 0
235 }
236
237 It 'Accepts openssl dgst -sha256 within lookahead window' {
238 $scriptPath = Join-Path $TestDrive 'openssl-check.sh'
239 Set-Content -Path $scriptPath -Value @(
240 'wget https://example.com/tool.zip -O /tmp/tool.zip'
241 'openssl dgst -sha256 /tmp/tool.zip'
242 )
243 $fileInfo = @{
244 Path = $scriptPath
245 Type = 'shell-downloads'
246 RelativePath = 'openssl-check.sh'
247 }
248 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
249 $result | Should -HaveCount 0
250 }
251
252 It 'Accepts checksum at lookahead boundary (line 5 after download)' {
253 $scriptPath = Join-Path $TestDrive 'boundary-check.sh'
254 # Download at line 1, checksum at line 6 (index 0+5 = within window)
255 $content = @(
256 'curl -o /tmp/tool.tar.gz https://example.com/tool.tar.gz'
257 'echo "line 2"'
258 'echo "line 3"'
259 'echo "line 4"'
260 'echo "line 5"'
261 'sha256sum -c /tmp/tool.tar.gz.sha256'
262 )
263 Set-Content -Path $scriptPath -Value $content
264 $fileInfo = @{
265 Path = $scriptPath
266 Type = 'shell-downloads'
267 RelativePath = 'boundary-check.sh'
268 }
269 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
270 $result | Should -HaveCount 0
271 }
272 }
273
274 Context 'Edge cases' {
275 It 'Returns empty array for empty file' {
276 $scriptPath = Join-Path $TestDrive 'empty.sh'
277 Set-Content -Path $scriptPath -Value ''
278 $fileInfo = @{
279 Path = $scriptPath
280 Type = 'shell-downloads'
281 RelativePath = 'empty.sh'
282 }
283 $result = @(Test-ShellDownloadSecurity -FileInfo $fileInfo)
284 $result | Should -HaveCount 0
285 }
286 }
287
288 Context 'File not found' {
289 It 'Returns empty array for non-existent file' {
290 $fileInfo = @{
291 Path = 'TestDrive:/nonexistent/file.sh'
292 Type = 'shell-downloads'
293 RelativePath = 'nonexistent/file.sh'
294 }
295 $result = Test-ShellDownloadSecurity -FileInfo $fileInfo
296 $result | Should -BeNullOrEmpty
297 }
298 }
299}
300
301Describe 'Get-DependencyViolation' -Tag 'Unit' {
302 Context 'Pinned workflows' {
303 It 'Returns no violations for fully pinned workflow' {
304 $pinnedPath = Join-Path $script:FixturesPath 'pinned-workflow.yml'
305 $fileInfo = @{
306 Path = $pinnedPath
307 Type = 'github-actions'
308 RelativePath = 'pinned-workflow.yml'
309 }
310 $result = Get-DependencyViolation -FileInfo $fileInfo
311 $result | Should -BeNullOrEmpty
312 }
313 }
314
315 Context 'Unpinned workflows' {
316 It 'Detects unpinned action references' {
317 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
318 $fileInfo = @{
319 Path = $unpinnedPath
320 Type = 'github-actions'
321 RelativePath = 'unpinned-workflow.yml'
322 }
323 $result = Get-DependencyViolation -FileInfo $fileInfo
324 $result | Should -Not -BeNullOrEmpty
325 $result.Count | Should -BeGreaterThan 0
326 }
327
328 It 'Returns correct violation type for unpinned actions' {
329 $unpinnedPath = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
330 $fileInfo = @{
331 Path = $unpinnedPath
332 Type = 'github-actions'
333 RelativePath = 'unpinned-workflow.yml'
334 }
335 $result = Get-DependencyViolation -FileInfo $fileInfo
336 $result[0].Type | Should -Be 'github-actions'
337 $result[0].Severity | Should -Be 'High'
338 $result[0].ViolationType | Should -Be 'Unpinned'
339 }
340 }
341
342 Context 'Mixed workflows' {
343 It 'Detects only unpinned actions in mixed workflow' {
344 $mixedPath = Join-Path $script:FixturesPath 'mixed-pinning-workflow.yml'
345 $fileInfo = @{
346 Path = $mixedPath
347 Type = 'github-actions'
348 RelativePath = 'mixed-pinning-workflow.yml'
349 }
350 $result = Get-DependencyViolation -FileInfo $fileInfo
351 $result | Should -Not -BeNullOrEmpty
352 # Should only detect the unpinned setup-node action
353 $result.Name | Should -Contain 'actions/setup-node'
354 }
355 }
356
357 Context 'Non-existent file' {
358 It 'Returns empty array for non-existent file' {
359 $fileInfo = @{
360 Path = 'TestDrive:/nonexistent/file.yml'
361 Type = 'github-actions'
362 RelativePath = 'file.yml'
363 }
364 $result = Get-DependencyViolation -FileInfo $fileInfo
365 $result | Should -BeNullOrEmpty
366 }
367 }
368}
369
370Describe 'Export-ComplianceReport' -Tag 'Unit' {
371 BeforeEach {
372 $script:TestOutputPath = Join-Path $TestDrive 'report'
373 New-Item -ItemType Directory -Path $script:TestOutputPath -Force | Out-Null
374
375 # Create a proper ComplianceReport class instance
376 $script:MockReport = [ComplianceReport]::new()
377 $script:MockReport.ScanPath = $script:FixturesPath
378 $script:MockReport.ComplianceScore = 50
379 $script:MockReport.TotalFiles = 3
380 $script:MockReport.ScannedFiles = 3
381 $script:MockReport.TotalDependencies = 4
382 $script:MockReport.PinnedDependencies = 2
383 $script:MockReport.UnpinnedDependencies = 2
384 $script:MockReport.Violations = @(
385 [PSCustomObject]@{
386 File = 'unpinned-workflow.yml'
387 Line = 10
388 Type = 'github-actions'
389 Name = 'actions/checkout'
390 Version = 'v4'
391 Severity = 'High'
392 Description = 'Unpinned dependency'
393 Remediation = 'Pin to SHA'
394 }
395 )
396 $script:MockReport.Summary = @{
397 'github-actions' = @{
398 Total = 4
399 High = 2
400 Medium = 0
401 Low = 0
402 }
403 }
404 }
405
406 Context 'JSON format' {
407 It 'Generates valid JSON report' {
408 $outputFile = Join-Path $script:TestOutputPath 'report.json'
409
410 Export-ComplianceReport -Report $script:MockReport -Format 'json' -OutputPath $outputFile
411
412 Test-Path $outputFile | Should -BeTrue
413 $content = Get-Content $outputFile -Raw | ConvertFrom-Json
414 $content | Should -Not -BeNullOrEmpty
415 }
416 }
417
418 Context 'SARIF format' {
419 BeforeAll {
420 $script:SarifFile = Join-Path $script:TestOutputPath 'report.sarif'
421
422 # Add a Medium severity violation for severity mapping coverage
423 $mediumViolation = [DependencyViolation]::new()
424 $mediumViolation.File = 'requirements.txt'
425 $mediumViolation.Line = 5
426 $mediumViolation.Type = 'pip'
427 $mediumViolation.Name = 'requests'
428 $mediumViolation.Version = '2.31.*'
429 $mediumViolation.Severity = 'Medium'
430 $mediumViolation.Description = 'Version range not pinned'
431 $mediumViolation.Remediation = 'Pin to exact version'
432 $script:MockReport.Violations += $mediumViolation
433
434 Export-ComplianceReport -Report $script:MockReport -Format 'sarif' -OutputPath $script:SarifFile
435 $script:SarifContent = Get-Content $script:SarifFile -Raw | ConvertFrom-Json
436 }
437
438 It 'Has valid SARIF version 2.1.0' {
439 $script:SarifContent.version | Should -BeExactly '2.1.0'
440 }
441
442 It 'References the SARIF 2.1.0 schema' {
443 $script:SarifContent.'$schema' | Should -Match 'sarif-2\.1\.0'
444 }
445
446 It 'Identifies dependency-pinning-analyzer as the tool driver' {
447 $script:SarifContent.runs[0].tool.driver.name | Should -BeExactly 'dependency-pinning-analyzer'
448 }
449
450 It 'Produces one result per violation' {
451 $script:SarifContent.runs[0].results.Count | Should -Be 2
452 }
453
454 It 'Maps High severity to error level' {
455 $highResult = $script:SarifContent.runs[0].results | Where-Object {
456 $_.properties.dependencyName -eq 'actions/checkout'
457 }
458 $highResult.level | Should -BeExactly 'error'
459 }
460
461 It 'Maps Medium severity to warning level' {
462 $mediumResult = $script:SarifContent.runs[0].results | Where-Object {
463 $_.properties.dependencyName -eq 'requests'
464 }
465 $mediumResult.level | Should -BeExactly 'warning'
466 }
467
468 It 'Includes file location with startLine greater than zero' {
469 $result = $script:SarifContent.runs[0].results[0]
470 $result.locations[0].physicalLocation.artifactLocation.uri | Should -Not -BeNullOrEmpty
471 $result.locations[0].physicalLocation.region.startLine | Should -BeGreaterThan 0
472 }
473
474 It 'Includes dependencyName and remediation in properties' {
475 $result = $script:SarifContent.runs[0].results[0]
476 $result.properties.dependencyName | Should -Not -BeNullOrEmpty
477 $result.properties.remediation | Should -Not -BeNullOrEmpty
478 }
479 }
480
481 Context 'Table format' {
482 It 'Generates table output without error' {
483 $outputFile = Join-Path $script:TestOutputPath 'report.txt'
484
485 { Export-ComplianceReport -Report $script:MockReport -Format 'table' -OutputPath $outputFile } | Should -Not -Throw
486 Test-Path $outputFile | Should -BeTrue
487 }
488 }
489
490 Context 'CSV format' {
491 It 'Generates CSV report' {
492 $outputFile = Join-Path $script:TestOutputPath 'report.csv'
493
494 Export-ComplianceReport -Report $script:MockReport -Format 'csv' -OutputPath $outputFile
495
496 Test-Path $outputFile | Should -BeTrue
497 }
498 }
499
500 Context 'Markdown format' {
501 It 'Generates Markdown report' {
502 $outputFile = Join-Path $script:TestOutputPath 'report.md'
503
504 Export-ComplianceReport -Report $script:MockReport -Format 'markdown' -OutputPath $outputFile
505
506 Test-Path $outputFile | Should -BeTrue
507 $content = Get-Content $outputFile -Raw
508 $content | Should -Match '# Dependency Pinning Compliance Report'
509 }
510 }
511}
512
513Describe 'ExcludePaths Filtering Logic' -Tag 'Unit' {
514 Context 'Pattern matching with -notlike operator' {
515 It 'Excludes paths containing pattern using -notlike wildcard' {
516 # Test the exclusion logic used in Get-FilesToScan:
517 # $files = $files | Where-Object { $_.FullName -notlike "*$exclude*" }
518 $testPaths = @(
519 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
520 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
521 )
522
523 $exclude = 'vendor'
524 $filtered = $testPaths | Where-Object { $_.FullName -notlike "*$exclude*" }
525
526 $filtered.Count | Should -Be 1
527 $filtered[0].FullName | Should -Not -Match 'vendor'
528 }
529
530 It 'Excludes multiple patterns correctly' {
531 $testPaths = @(
532 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
533 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
534 @{ FullName = 'C:\repo\node_modules\pkg\workflow.yml' }
535 )
536
537 $excludePatterns = @('vendor', 'node_modules')
538 $filtered = $testPaths
539 foreach ($exclude in $excludePatterns) {
540 $filtered = @($filtered | Where-Object { $_.FullName -notlike "*$exclude*" })
541 }
542
543 $filtered.Count | Should -Be 1
544 $filtered[0].FullName | Should -Be 'C:\repo\.github\workflows\test.yml'
545 }
546 }
547
548 Context 'Processes all files when ExcludePatterns is empty' {
549 It 'Returns all paths when no exclusion patterns provided' {
550 $testPaths = @(
551 @{ FullName = 'C:\repo\.github\workflows\test.yml' }
552 @{ FullName = 'C:\repo\vendor\.github\workflows\vendor.yml' }
553 )
554
555 $excludePatterns = @()
556 $filtered = $testPaths
557 if ($excludePatterns) {
558 foreach ($exclude in $excludePatterns) {
559 $filtered = $filtered | Where-Object { $_.FullName -notlike "*$exclude*" }
560 }
561 }
562
563 $filtered.Count | Should -Be 2
564 }
565 }
566
567 Context 'Comma-separated pattern parsing in main script' {
568 It 'Parses comma-separated exclude paths correctly' {
569 # Test the pattern used in main execution: $ExcludePaths.Split(',')
570 $excludePathsParam = 'vendor,node_modules,dist'
571 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
572
573 $patterns.Count | Should -Be 3
574 $patterns | Should -Contain 'vendor'
575 $patterns | Should -Contain 'node_modules'
576 $patterns | Should -Contain 'dist'
577 }
578
579 It 'Handles single pattern without comma' {
580 $excludePathsParam = 'vendor'
581 $patterns = $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() }
582
583 $patterns.Count | Should -Be 1
584 $patterns | Should -Contain 'vendor'
585 }
586
587 It 'Handles empty exclude paths' {
588 $excludePathsParam = ''
589 $patterns = if ($excludePathsParam) { $excludePathsParam.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
590
591 $patterns.Count | Should -Be 0
592 }
593 }
594
595 Context 'Pattern matching behavior' {
596 It 'Uses -notlike with wildcard for exclusion' {
597 $filePath = 'C:\repo\vendor\.github\workflows\test.yml'
598 $pattern = 'vendor'
599
600 # This matches how Get-FilesToScan uses: $_.FullName -notlike "*$exclude*"
601 $filePath -notlike "*$pattern*" | Should -BeFalse
602 }
603
604 It 'Passes through non-matching paths' {
605 $filePath = 'C:\repo\.github\workflows\release-stable.yml'
606 $pattern = 'vendor'
607
608 $filePath -notlike "*$pattern*" | Should -BeTrue
609 }
610 }
611}
612
613Describe 'pip ExcludePatterns integration' -Tag 'Unit' {
614 BeforeAll {
615 $pipTestRoot = Join-Path $TestDrive 'pip-exclude-test'
616 New-Item -Path $pipTestRoot -ItemType Directory -Force | Out-Null
617
618 # Root-level requirements file (should be scanned)
619 Set-Content -Path (Join-Path $pipTestRoot 'requirements.txt') -Value 'requests==2.31.0'
620
621 # Files inside excluded virtual environment directories (should be excluded)
622 $excludedDirs = @('.venv', 'venv', '.tox', '.nox', '__pypackages__')
623 foreach ($dir in $excludedDirs) {
624 $dirPath = Join-Path $pipTestRoot $dir
625 New-Item -Path $dirPath -ItemType Directory -Force | Out-Null
626 Set-Content -Path (Join-Path $dirPath 'requirements.txt') -Value 'flask==3.0.0'
627 }
628 }
629
630 It 'Excludes virtual environment directories from pip scans' {
631 $files = @(Get-FilesToScan -ScanPath $pipTestRoot -Types 'pip')
632 $files | Should -HaveCount 1
633 $files[0].RelativePath | Should -Be 'requirements.txt'
634 }
635
636 It 'Returns correct type metadata for pip files' {
637 $files = @(Get-FilesToScan -ScanPath $pipTestRoot -Types 'pip')
638 $files[0].Type | Should -Be 'pip'
639 }
640}
641
642Describe 'shell-downloads ExcludePatterns' -Tag 'Unit' {
643 BeforeAll {
644 $shellTestRoot = Join-Path $TestDrive 'shell-exclude-test'
645 $scriptsDir = Join-Path $shellTestRoot 'scripts'
646 New-Item -Path $scriptsDir -ItemType Directory -Force | Out-Null
647
648 # Script file that should be scanned
649 Set-Content -Path (Join-Path $scriptsDir 'install.sh') -Value 'echo hello'
650
651 # File inside Fixtures directory (should be excluded)
652 $fixturesDir = Join-Path $scriptsDir 'Fixtures'
653 New-Item -Path $fixturesDir -ItemType Directory -Force | Out-Null
654 Set-Content -Path (Join-Path $fixturesDir 'test-download.sh') -Value 'echo fixture'
655 }
656
657 It 'Excludes Fixtures directory from shell-downloads scans' {
658 $files = @(Get-FilesToScan -ScanPath $shellTestRoot -Types 'shell-downloads')
659 $files | Should -HaveCount 1
660 $files[0].RelativePath | Should -Be (Join-Path 'scripts' 'install.sh')
661 }
662
663 It 'Returns correct type metadata for shell-downloads files' {
664 $files = @(Get-FilesToScan -ScanPath $shellTestRoot -Types 'shell-downloads')
665 $files[0].Type | Should -Be 'shell-downloads'
666 }
667}
668
669Describe 'Dot-sourced execution protection' -Tag 'Unit' {
670 Context 'When script is dot-sourced' {
671 It 'Does not execute main block when dot-sourced' {
672 # Arrange
673 $testScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
674 $tempOutputPath = Join-Path $TestDrive 'dot-source-test.json'
675
676 # Act - Invoke in new process with dot-sourcing simulation
677 $scriptBlock = ". '$testScript' -OutputPath '$tempOutputPath'; [System.IO.File]::Exists('$tempOutputPath')"
678 pwsh -Command $scriptBlock 2>&1 | Out-Null
679
680 # Assert - Main execution should be skipped, no output file created
681 Test-Path $tempOutputPath | Should -BeFalse
682 }
683
684 }
685}
686
687Describe 'GitHub Actions error annotation' {
688 BeforeAll {
689 $script:OriginalGHA = $env:GITHUB_ACTIONS
690 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
691 }
692
693 AfterAll {
694 if ($null -eq $script:OriginalGHA) {
695 Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue
696 } else {
697 $env:GITHUB_ACTIONS = $script:OriginalGHA
698 }
699 }
700
701 Context 'Error handling with GitHub Actions' {
702 It 'Outputs GitHub error annotation on failure' {
703 # Arrange - Create a corrupted workflow file that will trigger an error
704 $testWorkflowDir = Join-Path $TestDrive 'test-workflows'
705 New-Item -ItemType Directory -Path (Join-Path $testWorkflowDir '.github/workflows') -Force | Out-Null
706 $corruptedFile = Join-Path $testWorkflowDir '.github/workflows/test.yml'
707 "uses: actions/checkout@invalid!!!" | Out-File -FilePath $corruptedFile -Encoding UTF8
708
709 # Act - Run script in new process with GITHUB_ACTIONS set
710 $scriptCommand = @"
711`$env:GITHUB_ACTIONS = 'true'
712& '$script:TestScript' -Path '$testWorkflowDir' -Format 'json' -OutputPath '$TestDrive/gha-test.json' -FailOnUnpinned 2>&1
713"@
714 $output = pwsh -Command $scriptCommand
715
716 # Assert - Should contain GitHub Actions error annotation or error output
717 # The script should execute and potentially generate warnings/errors
718 $output | Should -Not -BeNullOrEmpty
719 }
720 }
721}
722
723Describe 'Get-ComplianceReportData' -Tag 'Unit' {
724 BeforeAll {
725 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
726 }
727
728 Context 'Array coercion operations' {
729 It 'Handles empty violations array' {
730 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations @() -ScannedFiles @()
731
732 $result.TotalDependencies | Should -Be 0
733 $result.UnpinnedDependencies | Should -Be 0
734 $result.PinnedDependencies | Should -Be 0
735 $result.ComplianceScore | Should -Be 100.0
736 }
737
738 It 'Counts violations correctly with array coercion' {
739 $v1 = [DependencyViolation]::new()
740 $v1.Type = 'github-actions'
741 $v1.Severity = 'High'
742
743 $v2 = [DependencyViolation]::new()
744 $v2.Type = 'github-actions'
745 $v2.Severity = 'Medium'
746
747 $v3 = [DependencyViolation]::new()
748 $v3.Type = 'npm'
749 $v3.Severity = 'High'
750
751 $violations = @($v1, $v2, $v3)
752 $scannedFiles = @(@{ Path = 'test1.yml' }, @{ Path = 'test2.json' })
753
754 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
755
756 $result.TotalDependencies | Should -Be 3
757 $result.UnpinnedDependencies | Should -Be 3
758 }
759
760 It 'Groups violations by type with array coercion' {
761 $v1 = [DependencyViolation]::new()
762 $v1.Type = 'github-actions'
763 $v1.Severity = 'High'
764
765 $v2 = [DependencyViolation]::new()
766 $v2.Type = 'github-actions'
767 $v2.Severity = 'Low'
768
769 $v3 = [DependencyViolation]::new()
770 $v3.Type = 'npm'
771 $v3.Severity = 'Medium'
772
773 $violations = @($v1, $v2, $v3)
774 $scannedFiles = @(@{ Path = 'test.yml' })
775
776 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
777
778 $result.Summary.Keys | Should -Contain 'github-actions'
779 $result.Summary.Keys | Should -Contain 'npm'
780 $result.Summary['github-actions'].Total | Should -Be 2
781 $result.Summary['npm'].Total | Should -Be 1
782 }
783
784 It 'Counts severity levels correctly with array coercion' {
785 $violations = @()
786 for ($i = 0; $i -lt 4; $i++) {
787 $v = [DependencyViolation]::new()
788 $v.Type = 'github-actions'
789 $v.Severity = switch ($i) {
790 0 { 'High' }
791 1 { 'High' }
792 2 { 'Medium' }
793 3 { 'Low' }
794 }
795 $violations += $v
796 }
797 $scannedFiles = @(@{ Path = 'test.yml' })
798
799 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
800
801 $result.Summary['github-actions'].High | Should -Be 2
802 $result.Summary['github-actions'].Medium | Should -Be 1
803 $result.Summary['github-actions'].Low | Should -Be 1
804 }
805
806 It 'Handles single violation without PowerShell unrolling' {
807 $v = [DependencyViolation]::new()
808 $v.Type = 'github-actions'
809 $v.Severity = 'High'
810
811 $violations = @($v)
812 $scannedFiles = @(@{ Path = 'test.yml' })
813
814 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles
815
816 $result.TotalDependencies | Should -Be 1
817 $result.Summary['github-actions'].Total | Should -Be 1
818 $result.Summary['github-actions'].High | Should -Be 1
819 }
820 }
821}
822
823Describe 'Main Script Execution' {
824 BeforeAll {
825 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
826 $script:TestWorkspaceDir = Join-Path $TestDrive 'test-workspace'
827 New-Item -ItemType Directory -Path $script:TestWorkspaceDir -Force | Out-Null
828
829 # Create .github/workflows directory
830 $workflowDir = Join-Path $script:TestWorkspaceDir '.github/workflows'
831 New-Item -ItemType Directory -Path $workflowDir -Force | Out-Null
832 }
833
834 Context 'Array coercion in main execution block' {
835 It 'Executes array coercion when scanning files' {
836 # Create test workflow file
837 $workflowContent = @'
838name: Test
839on: push
840jobs:
841 test:
842 runs-on: ubuntu-latest
843 steps:
844 - uses: actions/checkout@v4
845'@
846 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/test.yml') -Value $workflowContent
847
848 $jsonPath = Join-Path $TestDrive 'scan-output.json'
849
850 # Execute script with array coercion operations
851 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
852
853 # Verify output was created (proves array operations executed)
854 Test-Path $jsonPath | Should -BeTrue
855 $result = Get-Content $jsonPath | ConvertFrom-Json
856 $result.PSObject.Properties.Name | Should -Contain 'ComplianceScore'
857 }
858
859 It 'Handles empty scan results with array coercion' {
860 # Remove workflow files
861 Remove-Item -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/*.yml') -Force -ErrorAction SilentlyContinue
862
863 # Create pinned workflow
864 $pinnedContent = @'
865name: Pinned
866on: push
867jobs:
868 test:
869 runs-on: ubuntu-latest
870 steps:
871 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
872'@
873 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/pinned.yml') -Value $pinnedContent
874
875 $jsonPath = Join-Path $TestDrive 'empty-output.json'
876
877 # Execute with all dependencies pinned (tests zero count array coercion)
878 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
879
880 Test-Path $jsonPath | Should -BeTrue
881 $result = Get-Content $jsonPath | ConvertFrom-Json
882 $result.UnpinnedDependencies | Should -Be 0
883 }
884 }
885}
886
887Describe 'Get-NpmDependencyViolations' -Tag 'Unit' {
888 BeforeAll {
889 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
890 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Npm'
891 }
892
893 Context 'Metadata-only package.json' {
894 It 'Returns zero violations for package with no dependencies' {
895 $fileInfo = @{
896 Path = Join-Path $script:FixturesPath 'metadata-only-package.json'
897 Type = 'npm'
898 RelativePath = 'metadata-only-package.json'
899 }
900
901 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
902
903 $violations.Count | Should -Be 0
904 }
905 }
906
907 Context 'Package.json with dependencies' {
908 It 'Detects unpinned dependencies in all sections' {
909 $fileInfo = @{
910 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
911 Type = 'npm'
912 RelativePath = 'with-dependencies-package.json'
913 }
914
915 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
916
917 $violations.Count | Should -BeGreaterThan 0
918 }
919
920 It 'Identifies correct dependency sections' {
921 $fileInfo = @{
922 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
923 Type = 'npm'
924 RelativePath = 'with-dependencies-package.json'
925 }
926
927 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
928 $sections = $violations | ForEach-Object { $_.Metadata.Section } | Sort-Object -Unique
929
930 $sections | Should -Contain 'dependencies'
931 $sections | Should -Contain 'devDependencies'
932 }
933
934 It 'Captures package name and version in violations' {
935 $fileInfo = @{
936 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
937 Type = 'npm'
938 RelativePath = 'with-dependencies-package.json'
939 }
940
941 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
942 $lodashViolation = $violations | Where-Object { $_.Name -eq 'lodash' }
943
944 $lodashViolation | Should -Not -BeNullOrEmpty
945 $lodashViolation.Name | Should -Be 'lodash'
946 $lodashViolation.Version | Should -Be '^4.17.21'
947 $lodashViolation.Severity | Should -Be 'Medium'
948 $lodashViolation.ViolationType | Should -Be 'Unpinned'
949 }
950
951 It 'Assigns valid line numbers to violations' {
952 $fileInfo = @{
953 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
954 Type = 'npm'
955 RelativePath = 'with-dependencies-package.json'
956 }
957
958 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
959
960 $violations | ForEach-Object { $_.Line | Should -BeGreaterOrEqual 1 }
961 }
962
963 It 'Excludes exact-version dependencies from violations' {
964 $fileInfo = @{
965 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
966 Type = 'npm'
967 RelativePath = 'with-dependencies-package.json'
968 }
969
970 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
971 $packageNames = $violations | ForEach-Object { $_.Name }
972
973 $packageNames | Should -Not -Contain 'jest'
974 }
975 }
976
977 Context 'Non-existent file' {
978 It 'Returns empty array for missing file' {
979 $fileInfo = @{
980 Path = 'C:\nonexistent\package.json'
981 Type = 'npm'
982 RelativePath = 'nonexistent/package.json'
983 }
984
985 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
986
987 $violations.Count | Should -Be 0
988 }
989 }
990
991 Context 'When package.json contains invalid JSON' {
992 BeforeAll {
993 $script:invalidJsonPath = Join-Path $script:FixturesPath 'invalid-json-package.json'
994 }
995
996 It 'Returns empty violations array on parse failure' {
997 $fileInfo = @{
998 Path = $script:invalidJsonPath
999 Type = 'npm'
1000 RelativePath = 'invalid-json-package.json'
1001 }
1002
1003 $violations = @(Get-NpmDependencyViolations -FileInfo $fileInfo)
1004
1005 $violations | Should -HaveCount 0
1006 }
1007
1008 It 'Emits a warning about parse failure' {
1009 $fileInfo = @{
1010 Path = $script:invalidJsonPath
1011 Type = 'npm'
1012 RelativePath = 'invalid-json-package.json'
1013 }
1014
1015 $warnings = Get-NpmDependencyViolations -FileInfo $fileInfo 3>&1
1016
1017 $warnings | Should -Not -BeNullOrEmpty
1018 $warnings | Should -Match 'Failed to parse.*as JSON'
1019 }
1020 }
1021
1022 Context 'When package.json contains empty or whitespace versions' {
1023 BeforeAll {
1024 $script:emptyVersionPath = Join-Path $script:FixturesPath 'empty-version-package.json'
1025 }
1026
1027 It 'Skips dependencies with empty versions' {
1028 $fileInfo = @{
1029 Path = $script:emptyVersionPath
1030 Type = 'npm'
1031 RelativePath = 'empty-version-package.json'
1032 }
1033
1034 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
1035 $packageNames = $violations | ForEach-Object { $_.Name }
1036
1037 $packageNames | Should -Not -Contain 'empty-version'
1038 $packageNames | Should -Not -Contain 'whitespace-version'
1039 }
1040
1041 It 'Reports violations for valid non-pinned versions in same file' {
1042 $fileInfo = @{
1043 Path = $script:emptyVersionPath
1044 Type = 'npm'
1045 RelativePath = 'empty-version-package.json'
1046 }
1047
1048 $violations = Get-NpmDependencyViolations -FileInfo $fileInfo
1049
1050 $violations.Count | Should -BeGreaterThan 0
1051 $violations | Where-Object { $_.Name -eq 'valid-package' } | Should -Not -BeNullOrEmpty
1052 }
1053 }
1054}
1055
1056Describe 'Get-RemediationSuggestion' -Tag 'Unit' {
1057 Context 'Without -Remediate flag' {
1058 It 'Returns enable-flag message' {
1059 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
1060 $v.Version = 'v4'
1061 $result = Get-RemediationSuggestion -Violation $v
1062 $result | Should -BeLike '*Enable -Remediate flag*'
1063 }
1064 }
1065
1066 Context 'GitHub Actions with -Remediate' {
1067 It 'Resolves SHA from API and returns pin suggestion' {
1068 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
1069 $v.Version = 'v4'
1070 $fakeSha = 'a'.PadRight(40, 'b')
1071 Mock Invoke-RestMethod { return @{ sha = $fakeSha } }
1072 $result = Get-RemediationSuggestion -Violation $v -Remediate
1073 $result | Should -BeLike "Pin to SHA: uses: actions/checkout@$fakeSha*"
1074 }
1075
1076 It 'Returns manual fallback when API throws' {
1077 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
1078 $v.Version = 'v4'
1079 Mock Invoke-RestMethod { throw 'API error' }
1080 Mock Write-SecurityLog {}
1081 $result = Get-RemediationSuggestion -Violation $v -Remediate
1082 $result | Should -Be 'Manually research and pin to immutable reference'
1083 }
1084 }
1085
1086 Context 'Non-github-actions type with -Remediate' {
1087 It 'Returns generic research message' {
1088 $v = [DependencyViolation]::new('req.txt', 1, 'pip', 'requests', 'Medium', 'desc')
1089 $v.Version = '2.31.0'
1090 $result = Get-RemediationSuggestion -Violation $v -Remediate
1091 $result | Should -BeLike '*Research and pin*pip*'
1092 }
1093 }
1094}
1095
1096Describe 'Get-DependencyViolation with ValidationFunc' -Tag 'Unit' {
1097 Context 'npm type triggers ValidationFunc path' {
1098 BeforeAll {
1099 $script:npmFixturePath = Join-Path $script:SecurityFixturesPath 'npm-violations'
1100 if (-not (Test-Path $script:npmFixturePath)) {
1101 New-Item -ItemType Directory -Path $script:npmFixturePath -Force | Out-Null
1102 }
1103 $script:pkgPath = Join-Path $script:npmFixturePath 'test-pkg.json'
1104 Set-Content -Path $script:pkgPath -Value '{"dependencies":{"lodash":"^4.17.21"}}'
1105 }
1106
1107 It 'Uses ValidationFunc instead of regex patterns' {
1108 $fileInfo = @{
1109 Path = $script:pkgPath
1110 Type = 'npm'
1111 RelativePath = 'test-pkg.json'
1112 }
1113 $violations = Get-DependencyViolation -FileInfo $fileInfo
1114 $violations | Should -Not -BeNullOrEmpty
1115 $violations[0].GetType().Name | Should -Be 'DependencyViolation'
1116 $violations[0].ViolationType | Should -Be 'Unpinned'
1117 }
1118
1119 It 'Sets File from FileInfo when missing' {
1120 $fileInfo = @{
1121 Path = $script:pkgPath
1122 Type = 'npm'
1123 RelativePath = 'test-pkg.json'
1124 }
1125 $violations = Get-DependencyViolation -FileInfo $fileInfo
1126 $violations | ForEach-Object { $_.File | Should -Not -BeNullOrEmpty }
1127 }
1128 }
1129}
1130
1131Describe 'Invoke-DependencyPinningAnalysis' -Tag 'Unit' {
1132 BeforeAll {
1133 Mock Get-FilesToScan { return @() }
1134 Mock Get-ComplianceReportData {
1135 return @{
1136 ComplianceScore = 100.0
1137 TotalDependencies = 0
1138 UnpinnedDependencies = 0
1139 Violations = @()
1140 }
1141 }
1142 Mock Export-ComplianceReport {}
1143 Mock Export-CICDArtifact {}
1144 }
1145
1146 Context 'All dependencies pinned' {
1147 It 'Logs success message without throwing' {
1148 { Invoke-DependencyPinningAnalysis -Path TestDrive: } | Should -Not -Throw
1149 }
1150
1151 It 'emits success Write-Host message when no violations' {
1152 Invoke-DependencyPinningAnalysis -Path TestDrive:
1153 Should -Invoke Write-Host -ParameterFilter {
1154 $Object -like '*✅*' -and $Object -like '*properly pinned*'
1155 }
1156 }
1157
1158 It 'does not emit Write-CIAnnotation warnings when no violations' {
1159 Invoke-DependencyPinningAnalysis -Path TestDrive:
1160 Should -Not -Invoke Write-CIAnnotation -ParameterFilter {
1161 $Level -eq 'Warning'
1162 }
1163 }
1164 }
1165
1166 Context 'Violations below threshold with -FailOnUnpinned' {
1167 BeforeAll {
1168 Mock Get-FilesToScan {
1169 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1170 }
1171 Mock Get-DependencyViolation {
1172 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1173 return @($v)
1174 }
1175 Mock Get-RemediationSuggestion { return 'pin it' }
1176 Mock Get-ComplianceReportData {
1177 return @{
1178 ComplianceScore = 50.0
1179 TotalDependencies = 2
1180 UnpinnedDependencies = 1
1181 Violations = @()
1182 }
1183 }
1184 }
1185
1186 It 'Throws when score below threshold and -FailOnUnpinned' {
1187 { Invoke-DependencyPinningAnalysis -Path TestDrive: -FailOnUnpinned -Threshold 80 } | Should -Throw '*below threshold*'
1188 }
1189
1190 It 'Does not throw in soft-fail mode' {
1191 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
1192 }
1193 }
1194
1195 Context 'CI output for violations in soft-fail mode' {
1196 BeforeAll {
1197 Mock Get-FilesToScan {
1198 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1199 }
1200 Mock Get-DependencyViolation {
1201 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1202 $v.CurrentRef = 'v4'
1203 return @($v)
1204 }
1205 Mock Get-RemediationSuggestion { return 'pin it' }
1206 Mock Get-ComplianceReportData {
1207 return @{
1208 ComplianceScore = 50.0
1209 TotalDependencies = 2
1210 UnpinnedDependencies = 1
1211 Violations = @()
1212 }
1213 }
1214 Mock Export-ComplianceReport {}
1215 Mock Export-CICDArtifact {}
1216 }
1217
1218 It 'emits summary header with violation count' {
1219 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1220 Should -Invoke Write-Host -ParameterFilter {
1221 $Object -like '*unpinned*'
1222 }
1223 }
1224
1225 It 'emits file header with file icon' {
1226 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1227 Should -Invoke Write-Host -ParameterFilter {
1228 $Object -like '*📄*'
1229 }
1230 }
1231
1232 It 'emits per-violation detail line' {
1233 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1234 Should -Invoke Write-Host -ParameterFilter {
1235 $Object -like '*❌*' -and $Object -like '*a/b*'
1236 }
1237 }
1238
1239 It 'emits Write-CIAnnotation with Error level for High severity violation' {
1240 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1241 Should -Invoke Write-CIAnnotation -ParameterFilter {
1242 $Level -eq 'Error' -and $File -eq 'f.yml' -and $Line -eq 1
1243 }
1244 }
1245 }
1246
1247 Context 'Score meets threshold' {
1248 BeforeAll {
1249 Mock Get-FilesToScan {
1250 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1251 }
1252 Mock Get-DependencyViolation {
1253 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'Low', 'desc')
1254 return @($v)
1255 }
1256 Mock Get-RemediationSuggestion { return 'pin it' }
1257 Mock Get-ComplianceReportData {
1258 return @{
1259 ComplianceScore = 90.0
1260 TotalDependencies = 10
1261 UnpinnedDependencies = 1
1262 Violations = @()
1263 }
1264 }
1265 }
1266
1267 It 'Does not throw when score meets threshold' {
1268 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
1269 }
1270 }
1271
1272 Context 'CI annotations per violation' {
1273 BeforeAll {
1274 Mock Write-CIAnnotation {}
1275 Mock Write-Host {}
1276 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1277 Mock Write-Host {} -ModuleName SecurityHelpers
1278 }
1279
1280 It 'Emits Write-CIAnnotation per violation' {
1281 Mock Get-FilesToScan {
1282 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1283 }
1284 Mock Get-DependencyViolation {
1285 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1286 $v.ViolationType = 'Unpinned'
1287 $v.Version = 'v4'
1288 return @($v)
1289 }
1290 Mock Get-RemediationSuggestion { return 'pin it' }
1291 Mock Get-ComplianceReportData {
1292 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1293 }
1294
1295 Invoke-DependencyPinningAnalysis -Path TestDrive:
1296
1297 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Error' -and $File -eq 'f.yml' -and $Line -eq 1 } -Times 1 -Exactly
1298 }
1299
1300 It 'Maps High severity to Error level' {
1301 Mock Get-FilesToScan {
1302 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1303 }
1304 Mock Get-DependencyViolation {
1305 $v = [DependencyViolation]::new('f.yml', 5, 'github-actions', 'actions/checkout', 'High', 'Unpinned action')
1306 $v.ViolationType = 'Unpinned'
1307 $v.Version = 'v4'
1308 return @($v)
1309 }
1310 Mock Get-RemediationSuggestion { return 'pin it' }
1311 Mock Get-ComplianceReportData {
1312 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1313 }
1314
1315 Invoke-DependencyPinningAnalysis -Path TestDrive:
1316
1317 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Error' -and $File -eq 'f.yml' } -Times 1 -Exactly
1318 }
1319
1320 It 'Maps Medium severity to Warning level' {
1321 Mock Get-FilesToScan {
1322 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1323 }
1324 Mock Get-DependencyViolation {
1325 $v = [DependencyViolation]::new('f.yml', 3, 'npm', 'lodash', 'Medium', 'Unpinned npm dep')
1326 $v.ViolationType = 'Unpinned'
1327 $v.Version = '^4.0.0'
1328 return @($v)
1329 }
1330 Mock Get-RemediationSuggestion { return 'pin it' }
1331 Mock Get-ComplianceReportData {
1332 return @{ ComplianceScore = 80.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1333 }
1334
1335 Invoke-DependencyPinningAnalysis -Path TestDrive:
1336
1337 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Warning' -and $File -eq 'f.yml' } -Times 1 -Exactly
1338 }
1339
1340 It 'Maps Low severity to Notice level' {
1341 Mock Get-FilesToScan {
1342 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1343 }
1344 Mock Get-DependencyViolation {
1345 $v = [DependencyViolation]::new('f.yml', 7, 'github-actions', 'a/b', 'Low', 'Minor issue')
1346 $v.ViolationType = 'MissingVersionComment'
1347 $v.Version = 'abc123'
1348 return @($v)
1349 }
1350 Mock Get-RemediationSuggestion { return 'add comment' }
1351 Mock Get-ComplianceReportData {
1352 return @{ ComplianceScore = 90.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1353 }
1354
1355 Invoke-DependencyPinningAnalysis -Path TestDrive:
1356
1357 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Notice' } -Times 1 -Exactly
1358 }
1359
1360 It 'Includes violation type in annotation message' {
1361 Mock Get-FilesToScan {
1362 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1363 }
1364 Mock Get-DependencyViolation {
1365 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1366 $v.ViolationType = 'Unpinned'
1367 $v.Version = 'v4'
1368 return @($v)
1369 }
1370 Mock Get-RemediationSuggestion { return 'pin it' }
1371 Mock Get-ComplianceReportData {
1372 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1373 }
1374
1375 Invoke-DependencyPinningAnalysis -Path TestDrive:
1376
1377 Should -Invoke Write-CIAnnotation -ParameterFilter { $Message -match 'Unpinned' }
1378 }
1379
1380 It 'Emits no annotations when no violations' {
1381 Mock Get-FilesToScan { return @() }
1382 Mock Get-ComplianceReportData {
1383 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1384 }
1385
1386 Invoke-DependencyPinningAnalysis -Path TestDrive:
1387
1388 Should -Invoke Write-CIAnnotation -Times 0
1389 }
1390
1391 It 'Emits multiple annotations for multiple violations' {
1392 Mock Get-FilesToScan {
1393 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1394 }
1395 Mock Get-DependencyViolation {
1396 $v1 = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1397 $v1.ViolationType = 'Unpinned'
1398 $v1.Version = 'v4'
1399 $v2 = [DependencyViolation]::new('f.yml', 5, 'github-actions', 'c/d', 'Medium', 'Also not pinned')
1400 $v2.ViolationType = 'Unpinned'
1401 $v2.Version = 'v3'
1402 return @($v1, $v2)
1403 }
1404 Mock Get-RemediationSuggestion { return 'pin it' }
1405 Mock Get-ComplianceReportData {
1406 return @{ ComplianceScore = 50.0; TotalDependencies = 2; UnpinnedDependencies = 2; Violations = @() }
1407 }
1408
1409 Invoke-DependencyPinningAnalysis -Path TestDrive:
1410
1411 Should -Invoke Write-CIAnnotation -ParameterFilter { $null -ne $File } -Times 2 -Exactly
1412 }
1413 }
1414
1415 Context 'Write-SecurityLog CI annotation forwarding' {
1416 BeforeAll {
1417 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1418 Mock Write-Host {} -ModuleName SecurityHelpers
1419 }
1420
1421 It 'Forwards Warning-level log messages as CI Warning annotations' {
1422 Mock Get-FilesToScan {
1423 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1424 }
1425 Mock Get-DependencyViolation {
1426 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1427 $v.ViolationType = 'Unpinned'
1428 $v.Version = 'v4'
1429 return @($v)
1430 }
1431 Mock Get-RemediationSuggestion { return 'pin it' }
1432 Mock Get-ComplianceReportData {
1433 return @{ ComplianceScore = 90.0; TotalDependencies = 2; UnpinnedDependencies = 1; Violations = @() }
1434 }
1435
1436 Invoke-DependencyPinningAnalysis -Path TestDrive:
1437
1438 # Write-SecurityLog -CIAnnotation "N dependencies require pinning..." emits a Warning annotation
1439 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Warning' -and $null -eq $File -and $Message -match 'pinning' }
1440 }
1441
1442 It 'Forwards Error-level log messages as CI Error annotations' {
1443 Mock Get-FilesToScan {
1444 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1445 }
1446 Mock Get-DependencyViolation {
1447 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1448 $v.ViolationType = 'Unpinned'
1449 $v.Version = 'v4'
1450 return @($v)
1451 }
1452 Mock Get-RemediationSuggestion { return 'pin it' }
1453 Mock Get-ComplianceReportData {
1454 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1455 }
1456
1457 Invoke-DependencyPinningAnalysis -Path TestDrive:
1458
1459 # Write-SecurityLog -CIAnnotation "Compliance score ... below threshold" emits an Error annotation
1460 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Error' -and $null -eq $File -and $Message -match 'below threshold' }
1461 }
1462
1463 It 'Does not forward Info-level log messages as annotations' {
1464 Mock Get-FilesToScan { return @() }
1465 Mock Get-ComplianceReportData {
1466 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1467 }
1468
1469 Invoke-DependencyPinningAnalysis -Path TestDrive:
1470
1471 # Info and Success levels should not produce CI annotations
1472 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $null -eq $File } -Times 0
1473 }
1474 }
1475
1476 Context 'Per-violation console output' {
1477 BeforeAll {
1478 Mock Write-CIAnnotation {}
1479 Mock Write-Host {}
1480 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1481 Mock Write-Host {} -ModuleName SecurityHelpers
1482 }
1483
1484 It 'Writes colored output for High severity violations' {
1485 Mock Get-FilesToScan {
1486 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1487 }
1488 Mock Get-DependencyViolation {
1489 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1490 $v.ViolationType = 'Unpinned'
1491 $v.Version = 'v4'
1492 return @($v)
1493 }
1494 Mock Get-RemediationSuggestion { return 'pin it' }
1495 Mock Get-ComplianceReportData {
1496 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1497 }
1498
1499 Invoke-DependencyPinningAnalysis -Path TestDrive:
1500
1501 Should -Invoke Write-Host -ParameterFilter { $ForegroundColor -eq 'Red' -and $Object -match 'a/b' }
1502 }
1503
1504 It 'Writes success message when no violations' {
1505 Mock Get-FilesToScan { return @() }
1506 Mock Get-ComplianceReportData {
1507 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1508 }
1509
1510 Invoke-DependencyPinningAnalysis -Path TestDrive:
1511
1512 Should -Invoke Write-Host -ParameterFilter { $ForegroundColor -eq 'Green' -and $Object -match 'properly pinned' }
1513 }
1514 }
1515}
1516
1517Describe 'Get-WorkflowNpmCommandViolations' -Tag 'Unit' {
1518 BeforeAll {
1519 # Source the script to get functions
1520 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
1521
1522 $script:fixtureDir = Join-Path $PSScriptRoot '../Fixtures/Workflows'
1523 }
1524
1525 Context 'when workflow contains npm install commands' {
1526 It 'should detect npm install in single-line run step' {
1527 $fileInfo = @{
1528 Path = Join-Path $script:fixtureDir 'workflow-npm-install.yml'
1529 Type = 'workflow-npm-commands'
1530 RelativePath = 'workflow-npm-install.yml'
1531 }
1532 $violations = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1533 $violations | Should -HaveCount 4
1534 }
1535
1536 It 'should return DependencyViolation objects' {
1537 $fileInfo = @{
1538 Path = Join-Path $script:fixtureDir 'workflow-npm-install.yml'
1539 Type = 'workflow-npm-commands'
1540 RelativePath = 'workflow-npm-install.yml'
1541 }
1542 $violations = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1543 $violations | ForEach-Object {
1544 $_.GetType().Name | Should -Be 'DependencyViolation'
1545 $_.Type | Should -Be 'workflow-npm-commands'
1546 $_.Severity | Should -Be 'Medium'
1547 $_.ViolationType | Should -Be 'Unpinned'
1548 }
1549 }
1550
1551 It 'should report accurate line numbers' {
1552 $fileInfo = @{
1553 Path = Join-Path $script:fixtureDir 'workflow-npm-install.yml'
1554 Type = 'workflow-npm-commands'
1555 RelativePath = 'workflow-npm-install.yml'
1556 }
1557 $violations = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1558 $violations | ForEach-Object {
1559 $_.Line | Should -BeGreaterThan 0
1560 }
1561 }
1562 }
1563
1564 Context 'when workflow contains only safe npm commands' {
1565 It 'should return no violations for npm ci and npm run' {
1566 $fileInfo = @{
1567 Path = Join-Path $script:fixtureDir 'workflow-npm-ci-only.yml'
1568 Type = 'workflow-npm-commands'
1569 RelativePath = 'workflow-npm-ci-only.yml'
1570 }
1571 $violations = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1572 $violations | Should -HaveCount 0
1573 }
1574 }
1575
1576 Context 'when file does not exist' {
1577 It 'should return empty array' {
1578 $fileInfo = @{
1579 Path = '/tmp/nonexistent-workflow.yml'
1580 Type = 'workflow-npm-commands'
1581 RelativePath = 'nonexistent.yml'
1582 }
1583 $violations = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1584 $violations | Should -HaveCount 0
1585 }
1586 }
1587
1588 Context 'edge cases with inline test data' {
1589 It 'should not flag commented-out npm install' {
1590 $yaml = @'
1591name: test
1592on: push
1593jobs:
1594 build:
1595 runs-on: ubuntu-latest
1596 steps:
1597 - name: Build
1598 run: |
1599 # npm install
1600 npm ci
1601'@
1602 $tempFile = Join-Path $TestDrive 'commented-npm.yml'
1603 Set-Content -Path $tempFile -Value $yaml
1604 $fileInfo = @{
1605 Path = $tempFile
1606 Type = 'workflow-npm-commands'
1607 RelativePath = 'commented-npm.yml'
1608 }
1609 $violations = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1610 $violations | Should -HaveCount 0
1611 }
1612
1613 It 'should detect npm install in multi-line block alongside safe commands' {
1614 $yaml = @'
1615name: test
1616on: push
1617jobs:
1618 build:
1619 runs-on: ubuntu-latest
1620 steps:
1621 - name: Setup
1622 run: |
1623 npm install
1624 npm run build
1625'@
1626 $tempFile = Join-Path $TestDrive 'mixed-npm.yml'
1627 Set-Content -Path $tempFile -Value $yaml
1628 $fileInfo = @{
1629 Path = $tempFile
1630 Type = 'workflow-npm-commands'
1631 RelativePath = 'mixed-npm.yml'
1632 }
1633 $violations = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1634 $violations | Should -HaveCount 1
1635 $violations[0].Name | Should -BeLike 'npm install*'
1636 }
1637 }
1638}
1639