microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/explain-repo-functionality

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

1710lines · 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.Violations | Should -Not -BeNullOrEmpty
125 $result.Violations[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.Violations | 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.Violations[0].File | Should -Be 'insecure-download.sh'
148 $result.Violations[0].Type | Should -Be 'shell-downloads'
149 $result.Violations[0].Line | Should -BeGreaterThan 0
150 $result.Violations[0].Description | Should -Be 'Download without checksum verification'
151 $result.Violations[0].Name | Should -Match 'curl.*https://'
152 $result.Violations[0].Severity | Should -Be 'Medium'
153 $result.Violations[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.Violations | 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.Violations | 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.Violations | 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.Violations | 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.Violations | 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.Violations | 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.Violations | 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.Violations | 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.Violations | Should -HaveCount 0
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.Violations | Should -HaveCount 0
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.Violations | Should -Not -BeNullOrEmpty
325 $result.Violations.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.Violations[0].Type | Should -Be 'github-actions'
337 $result.Violations[0].Severity | Should -Be 'High'
338 $result.Violations[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.Violations | Should -Not -BeNullOrEmpty
352 # Should only detect the unpinned setup-node action
353 $result.Violations.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.Violations | Should -HaveCount 0
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 @() -TotalDependencies 0
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 -TotalDependencies 3
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 -TotalDependencies 3
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 -TotalDependencies 4
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 -TotalDependencies 1
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 Context 'Partial compliance scoring' {
823 It 'Computes 60% score for 2 violations out of 5 dependencies' {
824 $v1 = [DependencyViolation]::new()
825 $v1.Type = 'github-actions'
826 $v1.Severity = 'High'
827 $v2 = [DependencyViolation]::new()
828 $v2.Type = 'github-actions'
829 $v2.Severity = 'Medium'
830
831 $violations = @($v1, $v2)
832 $scannedFiles = @(@{ Path = 'test.yml' })
833
834 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles -TotalDependencies 5
835
836 $result.ComplianceScore | Should -Be 60.0
837 $result.TotalDependencies | Should -Be 5
838 $result.PinnedDependencies | Should -Be 3
839 $result.UnpinnedDependencies | Should -Be 2
840 }
841
842 It 'Computes 90% score for 1 violation out of 10 dependencies' {
843 $v = [DependencyViolation]::new()
844 $v.Type = 'npm'
845 $v.Severity = 'Low'
846
847 $violations = @($v)
848 $scannedFiles = @(@{ Path = 'package.json' })
849
850 $result = Get-ComplianceReportData -ScanPath 'TestDrive:/' -Violations $violations -ScannedFiles $scannedFiles -TotalDependencies 10
851
852 $result.ComplianceScore | Should -Be 90.0
853 $result.TotalDependencies | Should -Be 10
854 $result.PinnedDependencies | Should -Be 9
855 $result.UnpinnedDependencies | Should -Be 1
856 }
857 }
858}
859
860Describe 'Main Script Execution' {
861 BeforeAll {
862 $script:TestScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1'
863 $script:TestWorkspaceDir = Join-Path $TestDrive 'test-workspace'
864 New-Item -ItemType Directory -Path $script:TestWorkspaceDir -Force | Out-Null
865
866 # Create .github/workflows directory
867 $workflowDir = Join-Path $script:TestWorkspaceDir '.github/workflows'
868 New-Item -ItemType Directory -Path $workflowDir -Force | Out-Null
869 }
870
871 Context 'Array coercion in main execution block' {
872 It 'Executes array coercion when scanning files' {
873 # Create test workflow file
874 $workflowContent = @'
875name: Test
876on: push
877jobs:
878 test:
879 runs-on: ubuntu-latest
880 steps:
881 - uses: actions/checkout@v4
882'@
883 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/test.yml') -Value $workflowContent
884
885 $jsonPath = Join-Path $TestDrive 'scan-output.json'
886
887 # Execute script with array coercion operations
888 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
889
890 # Verify output was created (proves array operations executed)
891 Test-Path $jsonPath | Should -BeTrue
892 $result = Get-Content $jsonPath | ConvertFrom-Json
893 $result.PSObject.Properties.Name | Should -Contain 'ComplianceScore'
894 }
895
896 It 'Handles empty scan results with array coercion' {
897 # Remove workflow files
898 Remove-Item -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/*.yml') -Force -ErrorAction SilentlyContinue
899
900 # Create pinned workflow
901 $pinnedContent = @'
902name: Pinned
903on: push
904jobs:
905 test:
906 runs-on: ubuntu-latest
907 steps:
908 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
909'@
910 Set-Content -Path (Join-Path $script:TestWorkspaceDir '.github/workflows/pinned.yml') -Value $pinnedContent
911
912 $jsonPath = Join-Path $TestDrive 'empty-output.json'
913
914 # Execute with all dependencies pinned (tests zero count array coercion)
915 & $script:TestScript -Path $script:TestWorkspaceDir -Format 'json' -OutputPath $jsonPath *>&1 | Out-Null
916
917 Test-Path $jsonPath | Should -BeTrue
918 $result = Get-Content $jsonPath | ConvertFrom-Json
919 $result.UnpinnedDependencies | Should -Be 0
920 }
921 }
922}
923
924Describe 'Get-NpmDependencyViolations' -Tag 'Unit' {
925 BeforeAll {
926 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
927 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Npm'
928 }
929
930 Context 'Metadata-only package.json' {
931 It 'Returns zero violations for package with no dependencies' {
932 $fileInfo = @{
933 Path = Join-Path $script:FixturesPath 'metadata-only-package.json'
934 Type = 'npm'
935 RelativePath = 'metadata-only-package.json'
936 }
937
938 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
939
940 $result.Violations | Should -HaveCount 0
941 }
942 }
943
944 Context 'Package.json with dependencies' {
945 It 'Detects unpinned dependencies in all sections' {
946 $fileInfo = @{
947 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
948 Type = 'npm'
949 RelativePath = 'with-dependencies-package.json'
950 }
951
952 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
953
954 $result.Violations.Count | Should -BeGreaterThan 0
955 }
956
957 It 'Identifies correct dependency sections' {
958 $fileInfo = @{
959 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
960 Type = 'npm'
961 RelativePath = 'with-dependencies-package.json'
962 }
963
964 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
965 $sections = $result.Violations | ForEach-Object { $_.Metadata.Section } | Sort-Object -Unique
966
967 $sections | Should -Contain 'dependencies'
968 $sections | Should -Contain 'devDependencies'
969 }
970
971 It 'Captures package name and version in violations' {
972 $fileInfo = @{
973 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
974 Type = 'npm'
975 RelativePath = 'with-dependencies-package.json'
976 }
977
978 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
979 $lodashViolation = $result.Violations | Where-Object { $_.Name -eq 'lodash' }
980
981 $lodashViolation | Should -Not -BeNullOrEmpty
982 $lodashViolation.Name | Should -Be 'lodash'
983 $lodashViolation.Version | Should -Be '^4.17.21'
984 $lodashViolation.Severity | Should -Be 'Medium'
985 $lodashViolation.ViolationType | Should -Be 'Unpinned'
986 }
987
988 It 'Assigns valid line numbers to violations' {
989 $fileInfo = @{
990 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
991 Type = 'npm'
992 RelativePath = 'with-dependencies-package.json'
993 }
994
995 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
996
997 $result.Violations | ForEach-Object { $_.Line | Should -BeGreaterOrEqual 1 }
998 }
999
1000 It 'Excludes exact-version dependencies from violations' {
1001 $fileInfo = @{
1002 Path = Join-Path $script:FixturesPath 'with-dependencies-package.json'
1003 Type = 'npm'
1004 RelativePath = 'with-dependencies-package.json'
1005 }
1006
1007 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
1008 $packageNames = $result.Violations | ForEach-Object { $_.Name }
1009
1010 $packageNames | Should -Not -Contain 'jest'
1011 }
1012 }
1013
1014 Context 'Non-existent file' {
1015 It 'Returns empty array for missing file' {
1016 $fileInfo = @{
1017 Path = 'C:\nonexistent\package.json'
1018 Type = 'npm'
1019 RelativePath = 'nonexistent/package.json'
1020 }
1021
1022 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
1023
1024 $result.Violations | Should -HaveCount 0
1025 }
1026 }
1027
1028 Context 'When package.json contains invalid JSON' {
1029 BeforeAll {
1030 $script:invalidJsonPath = Join-Path $script:FixturesPath 'invalid-json-package.json'
1031 }
1032
1033 It 'Returns empty violations array on parse failure' {
1034 $fileInfo = @{
1035 Path = $script:invalidJsonPath
1036 Type = 'npm'
1037 RelativePath = 'invalid-json-package.json'
1038 }
1039
1040 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
1041
1042 $result.Violations | Should -HaveCount 0
1043 }
1044
1045 It 'Emits a warning about parse failure' {
1046 $fileInfo = @{
1047 Path = $script:invalidJsonPath
1048 Type = 'npm'
1049 RelativePath = 'invalid-json-package.json'
1050 }
1051
1052 $output = Get-NpmDependencyViolations -FileInfo $fileInfo 3>&1
1053 $warnings = $output | Where-Object { $_ -is [System.Management.Automation.WarningRecord] }
1054
1055 $warnings | Should -Not -BeNullOrEmpty
1056 $warnings | Should -Match 'Failed to parse.*as JSON'
1057 }
1058 }
1059
1060 Context 'When package.json contains empty or whitespace versions' {
1061 BeforeAll {
1062 $script:emptyVersionPath = Join-Path $script:FixturesPath 'empty-version-package.json'
1063 }
1064
1065 It 'Skips dependencies with empty versions' {
1066 $fileInfo = @{
1067 Path = $script:emptyVersionPath
1068 Type = 'npm'
1069 RelativePath = 'empty-version-package.json'
1070 }
1071
1072 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
1073 $packageNames = $result.Violations | ForEach-Object { $_.Name }
1074
1075 $packageNames | Should -Not -Contain 'empty-version'
1076 $packageNames | Should -Not -Contain 'whitespace-version'
1077 }
1078
1079 It 'Reports violations for valid non-pinned versions in same file' {
1080 $fileInfo = @{
1081 Path = $script:emptyVersionPath
1082 Type = 'npm'
1083 RelativePath = 'empty-version-package.json'
1084 }
1085
1086 $result = Get-NpmDependencyViolations -FileInfo $fileInfo
1087
1088 $result.Violations.Count | Should -BeGreaterThan 0
1089 $result.Violations | Where-Object { $_.Name -eq 'valid-package' } | Should -Not -BeNullOrEmpty
1090 }
1091 }
1092}
1093
1094Describe 'Get-RemediationSuggestion' -Tag 'Unit' {
1095 Context 'Without -Remediate flag' {
1096 It 'Returns enable-flag message' {
1097 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
1098 $v.Version = 'v4'
1099 $result = Get-RemediationSuggestion -Violation $v
1100 $result | Should -BeLike '*Enable -Remediate flag*'
1101 }
1102 }
1103
1104 Context 'GitHub Actions with -Remediate' {
1105 It 'Resolves SHA from API and returns pin suggestion' {
1106 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
1107 $v.Version = 'v4'
1108 $fakeSha = 'a'.PadRight(40, 'b')
1109 Mock Invoke-RestMethod { return @{ sha = $fakeSha } }
1110 $result = Get-RemediationSuggestion -Violation $v -Remediate
1111 $result | Should -BeLike "Pin to SHA: uses: actions/checkout@$fakeSha*"
1112 }
1113
1114 It 'Returns manual fallback when API throws' {
1115 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc')
1116 $v.Version = 'v4'
1117 Mock Invoke-RestMethod { throw 'API error' }
1118 Mock Write-SecurityLog {}
1119 $result = Get-RemediationSuggestion -Violation $v -Remediate
1120 $result | Should -Be 'Manually research and pin to immutable reference'
1121 }
1122 }
1123
1124 Context 'Non-github-actions type with -Remediate' {
1125 It 'Returns generic research message' {
1126 $v = [DependencyViolation]::new('req.txt', 1, 'pip', 'requests', 'Medium', 'desc')
1127 $v.Version = '2.31.0'
1128 $result = Get-RemediationSuggestion -Violation $v -Remediate
1129 $result | Should -BeLike '*Research and pin*pip*'
1130 }
1131 }
1132}
1133
1134Describe 'Get-DependencyViolation with ValidationFunc' -Tag 'Unit' {
1135 Context 'npm type triggers ValidationFunc path' {
1136 BeforeAll {
1137 $script:npmFixturePath = Join-Path $script:SecurityFixturesPath 'npm-violations'
1138 if (-not (Test-Path $script:npmFixturePath)) {
1139 New-Item -ItemType Directory -Path $script:npmFixturePath -Force | Out-Null
1140 }
1141 $script:pkgPath = Join-Path $script:npmFixturePath 'test-pkg.json'
1142 Set-Content -Path $script:pkgPath -Value '{"dependencies":{"lodash":"^4.17.21"}}'
1143 }
1144
1145 It 'Uses ValidationFunc instead of regex patterns' {
1146 $fileInfo = @{
1147 Path = $script:pkgPath
1148 Type = 'npm'
1149 RelativePath = 'test-pkg.json'
1150 }
1151 $result = Get-DependencyViolation -FileInfo $fileInfo
1152 $result.Violations | Should -Not -BeNullOrEmpty
1153 $result.Violations[0].GetType().Name | Should -Be 'DependencyViolation'
1154 $result.Violations[0].ViolationType | Should -Be 'Unpinned'
1155 }
1156
1157 It 'Sets File from FileInfo when missing' {
1158 $fileInfo = @{
1159 Path = $script:pkgPath
1160 Type = 'npm'
1161 RelativePath = 'test-pkg.json'
1162 }
1163 $result = Get-DependencyViolation -FileInfo $fileInfo
1164 $result.Violations | ForEach-Object { $_.File | Should -Not -BeNullOrEmpty }
1165 }
1166 }
1167}
1168
1169Describe 'Invoke-DependencyPinningAnalysis' -Tag 'Unit' {
1170 BeforeAll {
1171 Mock Get-FilesToScan { return @() }
1172 Mock Get-ComplianceReportData {
1173 return @{
1174 ComplianceScore = 100.0
1175 TotalDependencies = 0
1176 UnpinnedDependencies = 0
1177 Violations = @()
1178 }
1179 }
1180 Mock Export-ComplianceReport {}
1181 Mock Export-CICDArtifact {}
1182 }
1183
1184 Context 'All dependencies pinned' {
1185 It 'Logs success message without throwing' {
1186 { Invoke-DependencyPinningAnalysis -Path TestDrive: } | Should -Not -Throw
1187 }
1188
1189 It 'emits success Write-Host message when no violations' {
1190 Invoke-DependencyPinningAnalysis -Path TestDrive:
1191 Should -Invoke Write-Host -ParameterFilter {
1192 $Object -like '*✅*' -and $Object -like '*properly pinned*'
1193 }
1194 }
1195
1196 It 'does not emit Write-CIAnnotation warnings when no violations' {
1197 Invoke-DependencyPinningAnalysis -Path TestDrive:
1198 Should -Not -Invoke Write-CIAnnotation -ParameterFilter {
1199 $Level -eq 'Warning'
1200 }
1201 }
1202 }
1203
1204 Context 'Violations below threshold with -FailOnUnpinned' {
1205 BeforeAll {
1206 Mock Get-FilesToScan {
1207 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1208 }
1209 Mock Get-DependencyViolation {
1210 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1211 return @{ TotalCount = 1; Violations = @($v) }
1212 }
1213 Mock Get-RemediationSuggestion { return 'pin it' }
1214 Mock Get-ComplianceReportData {
1215 return @{
1216 ComplianceScore = 50.0
1217 TotalDependencies = 2
1218 UnpinnedDependencies = 1
1219 Violations = @()
1220 }
1221 }
1222 }
1223
1224 It 'Throws when score below threshold and -FailOnUnpinned' {
1225 { Invoke-DependencyPinningAnalysis -Path TestDrive: -FailOnUnpinned -Threshold 80 } | Should -Throw '*below threshold*'
1226 }
1227
1228 It 'Does not throw in soft-fail mode' {
1229 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
1230 }
1231
1232 It 'Passes accumulated TotalDependencies to Get-ComplianceReportData' {
1233 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1234 Should -Invoke Get-ComplianceReportData -ParameterFilter { $TotalDependencies -eq 1 }
1235 }
1236 }
1237
1238 Context 'TotalDependencies accumulates across multiple files' {
1239 BeforeAll {
1240 Mock Get-FilesToScan {
1241 return @(
1242 @{ Path = 'TestDrive:\a.yml'; Type = 'github-actions'; RelativePath = 'a.yml' }
1243 @{ Path = 'TestDrive:\b.yml'; Type = 'github-actions'; RelativePath = 'b.yml' }
1244 )
1245 }
1246 Mock Get-DependencyViolation {
1247 $v = [DependencyViolation]::new('file.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1248 return @{ TotalCount = 3; Violations = @($v) }
1249 }
1250 Mock Get-RemediationSuggestion { return 'pin it' }
1251 Mock Get-ComplianceReportData {
1252 return @{
1253 ComplianceScore = 66.7
1254 TotalDependencies = 6
1255 UnpinnedDependencies = 2
1256 Violations = @()
1257 }
1258 }
1259 }
1260
1261 It 'Sums TotalCount from each file scan result' {
1262 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 50
1263 Should -Invoke Get-ComplianceReportData -ParameterFilter { $TotalDependencies -eq 6 }
1264 }
1265 }
1266
1267 Context 'CI output for violations in soft-fail mode' {
1268 BeforeAll {
1269 Mock Get-FilesToScan {
1270 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1271 }
1272 Mock Get-DependencyViolation {
1273 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1274 $v.CurrentRef = 'v4'
1275 return @{ TotalCount = 1; Violations = @($v) }
1276 }
1277 Mock Get-RemediationSuggestion { return 'pin it' }
1278 Mock Get-ComplianceReportData {
1279 return @{
1280 ComplianceScore = 50.0
1281 TotalDependencies = 2
1282 UnpinnedDependencies = 1
1283 Violations = @()
1284 }
1285 }
1286 Mock Export-ComplianceReport {}
1287 Mock Export-CICDArtifact {}
1288 }
1289
1290 It 'emits summary header with violation count' {
1291 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1292 Should -Invoke Write-Host -ParameterFilter {
1293 $Object -like '*unpinned*'
1294 }
1295 }
1296
1297 It 'emits file header with file icon' {
1298 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1299 Should -Invoke Write-Host -ParameterFilter {
1300 $Object -like '*📄*'
1301 }
1302 }
1303
1304 It 'emits per-violation detail line' {
1305 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1306 Should -Invoke Write-Host -ParameterFilter {
1307 $Object -like '*❌*' -and $Object -like '*a/b*'
1308 }
1309 }
1310
1311 It 'emits Write-CIAnnotation with Error level for High severity violation' {
1312 Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80
1313 Should -Invoke Write-CIAnnotation -ParameterFilter {
1314 $Level -eq 'Error' -and $File -eq 'f.yml' -and $Line -eq 1
1315 }
1316 }
1317 }
1318
1319 Context 'Score meets threshold' {
1320 BeforeAll {
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', 1, 'github-actions', 'a/b', 'Low', 'desc')
1326 return @{ TotalCount = 1; Violations = @($v) }
1327 }
1328 Mock Get-RemediationSuggestion { return 'pin it' }
1329 Mock Get-ComplianceReportData {
1330 return @{
1331 ComplianceScore = 90.0
1332 TotalDependencies = 10
1333 UnpinnedDependencies = 1
1334 Violations = @()
1335 }
1336 }
1337 }
1338
1339 It 'Does not throw when score meets threshold' {
1340 { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw
1341 }
1342 }
1343
1344 Context 'CI annotations per violation' {
1345 BeforeAll {
1346 Mock Write-CIAnnotation {}
1347 Mock Write-Host {}
1348 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1349 Mock Write-Host {} -ModuleName SecurityHelpers
1350 }
1351
1352 It 'Emits Write-CIAnnotation per violation' {
1353 Mock Get-FilesToScan {
1354 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1355 }
1356 Mock Get-DependencyViolation {
1357 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1358 $v.ViolationType = 'Unpinned'
1359 $v.Version = 'v4'
1360 return @{ TotalCount = 1; Violations = @($v) }
1361 }
1362 Mock Get-RemediationSuggestion { return 'pin it' }
1363 Mock Get-ComplianceReportData {
1364 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1365 }
1366
1367 Invoke-DependencyPinningAnalysis -Path TestDrive:
1368
1369 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Error' -and $File -eq 'f.yml' -and $Line -eq 1 } -Times 1 -Exactly
1370 }
1371
1372 It 'Maps High severity to Error level' {
1373 Mock Get-FilesToScan {
1374 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1375 }
1376 Mock Get-DependencyViolation {
1377 $v = [DependencyViolation]::new('f.yml', 5, 'github-actions', 'actions/checkout', 'High', 'Unpinned action')
1378 $v.ViolationType = 'Unpinned'
1379 $v.Version = 'v4'
1380 return @{ TotalCount = 1; Violations = @($v) }
1381 }
1382 Mock Get-RemediationSuggestion { return 'pin it' }
1383 Mock Get-ComplianceReportData {
1384 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1385 }
1386
1387 Invoke-DependencyPinningAnalysis -Path TestDrive:
1388
1389 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Error' -and $File -eq 'f.yml' } -Times 1 -Exactly
1390 }
1391
1392 It 'Maps Medium severity to Warning level' {
1393 Mock Get-FilesToScan {
1394 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1395 }
1396 Mock Get-DependencyViolation {
1397 $v = [DependencyViolation]::new('f.yml', 3, 'npm', 'lodash', 'Medium', 'Unpinned npm dep')
1398 $v.ViolationType = 'Unpinned'
1399 $v.Version = '^4.0.0'
1400 return @{ TotalCount = 1; Violations = @($v) }
1401 }
1402 Mock Get-RemediationSuggestion { return 'pin it' }
1403 Mock Get-ComplianceReportData {
1404 return @{ ComplianceScore = 80.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1405 }
1406
1407 Invoke-DependencyPinningAnalysis -Path TestDrive:
1408
1409 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Warning' -and $File -eq 'f.yml' } -Times 1 -Exactly
1410 }
1411
1412 It 'Maps Low severity to Notice level' {
1413 Mock Get-FilesToScan {
1414 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1415 }
1416 Mock Get-DependencyViolation {
1417 $v = [DependencyViolation]::new('f.yml', 7, 'github-actions', 'a/b', 'Low', 'Minor issue')
1418 $v.ViolationType = 'MissingVersionComment'
1419 $v.Version = 'abc123'
1420 return @{ TotalCount = 1; Violations = @($v) }
1421 }
1422 Mock Get-RemediationSuggestion { return 'add comment' }
1423 Mock Get-ComplianceReportData {
1424 return @{ ComplianceScore = 90.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1425 }
1426
1427 Invoke-DependencyPinningAnalysis -Path TestDrive:
1428
1429 Should -Invoke Write-CIAnnotation -ParameterFilter { $Level -eq 'Notice' } -Times 1 -Exactly
1430 }
1431
1432 It 'Includes violation type in annotation message' {
1433 Mock Get-FilesToScan {
1434 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1435 }
1436 Mock Get-DependencyViolation {
1437 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1438 $v.ViolationType = 'Unpinned'
1439 $v.Version = 'v4'
1440 return @{ TotalCount = 1; Violations = @($v) }
1441 }
1442 Mock Get-RemediationSuggestion { return 'pin it' }
1443 Mock Get-ComplianceReportData {
1444 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1445 }
1446
1447 Invoke-DependencyPinningAnalysis -Path TestDrive:
1448
1449 Should -Invoke Write-CIAnnotation -ParameterFilter { $Message -match 'Unpinned' }
1450 }
1451
1452 It 'Emits no annotations when no violations' {
1453 Mock Get-FilesToScan { return @() }
1454 Mock Get-ComplianceReportData {
1455 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1456 }
1457
1458 Invoke-DependencyPinningAnalysis -Path TestDrive:
1459
1460 Should -Invoke Write-CIAnnotation -Times 0
1461 }
1462
1463 It 'Emits multiple annotations for multiple violations' {
1464 Mock Get-FilesToScan {
1465 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1466 }
1467 Mock Get-DependencyViolation {
1468 $v1 = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1469 $v1.ViolationType = 'Unpinned'
1470 $v1.Version = 'v4'
1471 $v2 = [DependencyViolation]::new('f.yml', 5, 'github-actions', 'c/d', 'Medium', 'Also not pinned')
1472 $v2.ViolationType = 'Unpinned'
1473 $v2.Version = 'v3'
1474 return @{ TotalCount = 2; Violations = @($v1, $v2) }
1475 }
1476 Mock Get-RemediationSuggestion { return 'pin it' }
1477 Mock Get-ComplianceReportData {
1478 return @{ ComplianceScore = 50.0; TotalDependencies = 2; UnpinnedDependencies = 2; Violations = @() }
1479 }
1480
1481 Invoke-DependencyPinningAnalysis -Path TestDrive:
1482
1483 Should -Invoke Write-CIAnnotation -ParameterFilter { $null -ne $File } -Times 2 -Exactly
1484 }
1485 }
1486
1487 Context 'Write-SecurityLog CI annotation forwarding' {
1488 BeforeAll {
1489 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1490 Mock Write-Host {} -ModuleName SecurityHelpers
1491 }
1492
1493 It 'Forwards Warning-level log messages as CI Warning annotations' {
1494 Mock Get-FilesToScan {
1495 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1496 }
1497 Mock Get-DependencyViolation {
1498 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1499 $v.ViolationType = 'Unpinned'
1500 $v.Version = 'v4'
1501 return @{ TotalCount = 1; Violations = @($v) }
1502 }
1503 Mock Get-RemediationSuggestion { return 'pin it' }
1504 Mock Get-ComplianceReportData {
1505 return @{ ComplianceScore = 90.0; TotalDependencies = 2; UnpinnedDependencies = 1; Violations = @() }
1506 }
1507
1508 Invoke-DependencyPinningAnalysis -Path TestDrive:
1509
1510 # Write-SecurityLog -CIAnnotation "N dependencies require pinning..." emits a Warning annotation
1511 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Warning' -and $null -eq $File -and $Message -match 'pinning' }
1512 }
1513
1514 It 'Forwards Error-level log messages as CI Error annotations' {
1515 Mock Get-FilesToScan {
1516 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1517 }
1518 Mock Get-DependencyViolation {
1519 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1520 $v.ViolationType = 'Unpinned'
1521 $v.Version = 'v4'
1522 return @{ TotalCount = 1; Violations = @($v) }
1523 }
1524 Mock Get-RemediationSuggestion { return 'pin it' }
1525 Mock Get-ComplianceReportData {
1526 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1527 }
1528
1529 Invoke-DependencyPinningAnalysis -Path TestDrive:
1530
1531 # Write-SecurityLog -CIAnnotation "Compliance score ... below threshold" emits an Error annotation
1532 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Error' -and $null -eq $File -and $Message -match 'below threshold' }
1533 }
1534
1535 It 'Does not forward Info-level log messages as annotations' {
1536 Mock Get-FilesToScan { return @() }
1537 Mock Get-ComplianceReportData {
1538 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1539 }
1540
1541 Invoke-DependencyPinningAnalysis -Path TestDrive:
1542
1543 # Info and Success levels should not produce CI annotations
1544 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $null -eq $File } -Times 0
1545 }
1546 }
1547
1548 Context 'Per-violation console output' {
1549 BeforeAll {
1550 Mock Write-CIAnnotation {}
1551 Mock Write-Host {}
1552 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
1553 Mock Write-Host {} -ModuleName SecurityHelpers
1554 }
1555
1556 It 'Writes colored output for High severity violations' {
1557 Mock Get-FilesToScan {
1558 return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' })
1559 }
1560 Mock Get-DependencyViolation {
1561 $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned')
1562 $v.ViolationType = 'Unpinned'
1563 $v.Version = 'v4'
1564 return @{ TotalCount = 1; Violations = @($v) }
1565 }
1566 Mock Get-RemediationSuggestion { return 'pin it' }
1567 Mock Get-ComplianceReportData {
1568 return @{ ComplianceScore = 50.0; TotalDependencies = 1; UnpinnedDependencies = 1; Violations = @() }
1569 }
1570
1571 Invoke-DependencyPinningAnalysis -Path TestDrive:
1572
1573 Should -Invoke Write-Host -ParameterFilter { $ForegroundColor -eq 'Red' -and $Object -match 'a/b' }
1574 }
1575
1576 It 'Writes success message when no violations' {
1577 Mock Get-FilesToScan { return @() }
1578 Mock Get-ComplianceReportData {
1579 return @{ ComplianceScore = 100.0; TotalDependencies = 0; UnpinnedDependencies = 0; Violations = @() }
1580 }
1581
1582 Invoke-DependencyPinningAnalysis -Path TestDrive:
1583
1584 Should -Invoke Write-Host -ParameterFilter { $ForegroundColor -eq 'Green' -and $Object -match 'properly pinned' }
1585 }
1586 }
1587}
1588
1589Describe 'Get-WorkflowNpmCommandViolations' -Tag 'Unit' {
1590 BeforeAll {
1591 # Source the script to get functions
1592 . $PSScriptRoot/../../security/Test-DependencyPinning.ps1
1593
1594 $script:fixtureDir = Join-Path $PSScriptRoot '../Fixtures/Workflows'
1595 }
1596
1597 Context 'when workflow contains npm install commands' {
1598 It 'should detect npm install in single-line run step' {
1599 $fileInfo = @{
1600 Path = Join-Path $script:fixtureDir 'workflow-npm-install.yml'
1601 Type = 'workflow-npm-commands'
1602 RelativePath = 'workflow-npm-install.yml'
1603 }
1604 $result = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1605 $result.Violations | Should -HaveCount 4
1606 }
1607
1608 It 'should return DependencyViolation objects' {
1609 $fileInfo = @{
1610 Path = Join-Path $script:fixtureDir 'workflow-npm-install.yml'
1611 Type = 'workflow-npm-commands'
1612 RelativePath = 'workflow-npm-install.yml'
1613 }
1614 $result = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1615 $result.Violations | ForEach-Object {
1616 $_.GetType().Name | Should -Be 'DependencyViolation'
1617 $_.Type | Should -Be 'workflow-npm-commands'
1618 $_.Severity | Should -Be 'Medium'
1619 $_.ViolationType | Should -Be 'Unpinned'
1620 }
1621 }
1622
1623 It 'should report accurate line numbers' {
1624 $fileInfo = @{
1625 Path = Join-Path $script:fixtureDir 'workflow-npm-install.yml'
1626 Type = 'workflow-npm-commands'
1627 RelativePath = 'workflow-npm-install.yml'
1628 }
1629 $result = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1630 $result.Violations | ForEach-Object {
1631 $_.Line | Should -BeGreaterThan 0
1632 }
1633 }
1634 }
1635
1636 Context 'when workflow contains only safe npm commands' {
1637 It 'should return no violations for npm ci and npm run' {
1638 $fileInfo = @{
1639 Path = Join-Path $script:fixtureDir 'workflow-npm-ci-only.yml'
1640 Type = 'workflow-npm-commands'
1641 RelativePath = 'workflow-npm-ci-only.yml'
1642 }
1643 $result = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1644 $result.Violations | Should -HaveCount 0
1645 }
1646 }
1647
1648 Context 'when file does not exist' {
1649 It 'should return empty array' {
1650 $fileInfo = @{
1651 Path = '/tmp/nonexistent-workflow.yml'
1652 Type = 'workflow-npm-commands'
1653 RelativePath = 'nonexistent.yml'
1654 }
1655 $result = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1656 $result.Violations | Should -HaveCount 0
1657 }
1658 }
1659
1660 Context 'edge cases with inline test data' {
1661 It 'should not flag commented-out npm install' {
1662 $yaml = @'
1663name: test
1664on: push
1665jobs:
1666 build:
1667 runs-on: ubuntu-latest
1668 steps:
1669 - name: Build
1670 run: |
1671 # npm install
1672 npm ci
1673'@
1674 $tempFile = Join-Path $TestDrive 'commented-npm.yml'
1675 Set-Content -Path $tempFile -Value $yaml
1676 $fileInfo = @{
1677 Path = $tempFile
1678 Type = 'workflow-npm-commands'
1679 RelativePath = 'commented-npm.yml'
1680 }
1681 $result = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1682 $result.Violations | Should -HaveCount 0
1683 }
1684
1685 It 'should detect npm install in multi-line block alongside safe commands' {
1686 $yaml = @'
1687name: test
1688on: push
1689jobs:
1690 build:
1691 runs-on: ubuntu-latest
1692 steps:
1693 - name: Setup
1694 run: |
1695 npm install
1696 npm run build
1697'@
1698 $tempFile = Join-Path $TestDrive 'mixed-npm.yml'
1699 Set-Content -Path $tempFile -Value $yaml
1700 $fileInfo = @{
1701 Path = $tempFile
1702 Type = 'workflow-npm-commands'
1703 RelativePath = 'mixed-npm.yml'
1704 }
1705 $result = Get-WorkflowNpmCommandViolations -FileInfo $fileInfo
1706 $result.Violations | Should -HaveCount 1
1707 $result.Violations[0].Name | Should -BeLike 'npm install*'
1708 }
1709 }
1710}
1711