microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3a3a0fdf923d96a9e8a9ac734c73f24433b525e8

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/Update-ActionSHAPinning.Tests.ps1

803lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5BeforeAll {
6 $scriptPath = Join-Path $PSScriptRoot '../../security/Update-ActionSHAPinning.ps1'
7 . $scriptPath
8
9 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
10 Import-Module $mockPath -Force
11
12 # Save environment before tests
13 Save-CIEnvironment
14
15 # Fixture paths
16 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Workflows'
17
18 # Mock response helpers
19 function script:New-MockGitHubGraphQLResponse {
20 param(
21 [string]$Login = 'testuser',
22 [int]$RateRemaining = 5000,
23 [int]$RateLimit = 5000
24 )
25 return @{
26 data = @{
27 viewer = @{ login = $Login }
28 rateLimit = @{
29 remaining = $RateRemaining
30 limit = $RateLimit
31 resetAt = (Get-Date).AddHours(1).ToString('o')
32 }
33 }
34 }
35 }
36
37 function script:New-MockRateLimitException {
38 $exception = [System.Net.WebException]::new(
39 "API rate limit exceeded",
40 $null,
41 [System.Net.WebExceptionStatus]::ProtocolError,
42 $null
43 )
44 return $exception
45 }
46}
47
48AfterAll {
49 Restore-CIEnvironment
50}
51
52Describe 'Get-ActionReference' -Tag 'Unit' {
53 Context 'Standard action references' {
54 It 'Parses action with tag reference' {
55 $yaml = 'uses: actions/checkout@v4'
56 $result = Get-ActionReference -WorkflowContent $yaml
57 $result | Should -Not -BeNullOrEmpty
58 $result.OriginalRef | Should -Be 'actions/checkout@v4'
59 }
60
61 It 'Parses action with SHA reference' {
62 $yaml = 'uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29'
63 $result = Get-ActionReference -WorkflowContent $yaml
64 $result.OriginalRef | Should -Be 'actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29'
65 }
66
67 It 'Returns LineNumber for reference' {
68 $yaml = "name: Test`njobs:`n test:`n steps:`n - name: Checkout`n uses: actions/checkout@v4"
69 $result = Get-ActionReference -WorkflowContent $yaml
70 $result.LineNumber | Should -BeGreaterThan 0
71 }
72 }
73
74 Context 'Multiple action references' {
75 It 'Finds all action references in workflow' {
76 $yaml = "jobs:`n test:`n steps:`n - name: Checkout`n uses: actions/checkout@v4`n - name: Setup`n uses: actions/setup-node@v4"
77 $result = @(Get-ActionReference -WorkflowContent $yaml)
78 $result.Count | Should -Be 2
79 }
80 }
81
82 Context 'Invalid references' {
83 It 'Returns empty for non-action content' {
84 $yaml = 'run: echo "Hello"'
85 $result = Get-ActionReference -WorkflowContent $yaml
86 $result | Should -BeNullOrEmpty
87 }
88 }
89}
90
91Describe 'Get-SHAForAction' -Tag 'Unit' {
92 BeforeEach {
93 Initialize-MockCIEnvironment
94 $env:GITHUB_TOKEN = 'ghp_test123456789'
95 }
96
97 AfterEach {
98 Clear-MockCIEnvironment
99 }
100
101 Context 'ActionSHAMap lookup' {
102 It 'Returns action reference with SHA for known action' {
103 $result = Get-SHAForAction -ActionRef 'actions/checkout@v4'
104 $result | Should -Not -BeNullOrEmpty
105 # Function returns full action reference with SHA (e.g., actions/checkout@sha)
106 $result | Should -Match '@[a-f0-9]{40}$'
107 }
108 }
109
110 Context 'Unmapped actions' {
111 It 'Returns null when action not in map and no API call is made' {
112 # Get-SHAForAction returns null for unmapped actions without attempting API
113 $result = Get-SHAForAction -ActionRef 'unknown/action@v1'
114 $result | Should -BeNullOrEmpty
115 }
116
117 It 'Returns null for unmapped actions requiring manual review' {
118 # The function logs a warning and returns null for unmapped actions
119 $result = Get-SHAForAction -ActionRef 'test-org/test-action@v1'
120 $result | Should -BeNullOrEmpty
121 }
122 }
123}
124
125Describe 'Update-WorkflowFile' -Tag 'Unit' {
126 BeforeEach {
127 Initialize-MockCIEnvironment
128 $env:GITHUB_TOKEN = 'ghp_test123456789'
129
130 # Copy fixture to TestDrive for modification testing
131 $unpinnedSource = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
132 $script:TestWorkflow = Join-Path $TestDrive 'test-workflow.yml'
133 Copy-Item $unpinnedSource $script:TestWorkflow
134
135 Mock Invoke-RestMethod {
136 return @{
137 object = @{
138 sha = 'newsha123456789012345678901234567890abcd'
139 }
140 }
141 }
142 }
143
144 AfterEach {
145 Clear-MockCIEnvironment
146 }
147
148 Context 'Return value structure' {
149 It 'Returns PSCustomObject with FilePath' {
150 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
151 $result | Should -BeOfType [PSCustomObject]
152 $result.FilePath | Should -Be $script:TestWorkflow
153 }
154
155 It 'Returns ActionsProcessed count' {
156 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
157 $result.ActionsProcessed | Should -BeGreaterOrEqual 0
158 }
159
160 It 'Returns ActionsPinned count' {
161 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
162 $result.PSObject.Properties.Name -contains 'ActionsPinned' | Should -BeTrue
163 }
164 }
165
166 Context 'File modification' {
167 It 'Updates unpinned action to SHA' {
168 Update-WorkflowFile -FilePath $script:TestWorkflow
169
170 $content = Get-Content $script:TestWorkflow -Raw
171 # Check that the file was processed (content may or may not change based on mock)
172 $content | Should -Not -BeNullOrEmpty
173 }
174 }
175
176 Context 'Already pinned workflows' {
177 It 'Does not modify already pinned actions' {
178 $pinnedSource = Join-Path $script:FixturesPath 'pinned-workflow.yml'
179 $pinnedTest = Join-Path $TestDrive 'pinned-test.yml'
180 Copy-Item $pinnedSource $pinnedTest
181
182 $originalContent = Get-Content $pinnedTest -Raw
183 Update-WorkflowFile -FilePath $pinnedTest
184 $newContent = Get-Content $pinnedTest -Raw
185
186 $newContent | Should -Be $originalContent
187 }
188 }
189}
190
191Describe 'Update-WorkflowFile -WhatIf' -Tag 'Unit' {
192 BeforeEach {
193 Initialize-MockCIEnvironment
194 $env:GITHUB_TOKEN = 'ghp_test123456789'
195
196 $unpinnedSource = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
197 $script:TestWorkflow = Join-Path $TestDrive 'whatif-test.yml'
198 Copy-Item $unpinnedSource $script:TestWorkflow
199
200 Mock Invoke-RestMethod {
201 return @{
202 object = @{
203 sha = 'newsha123456789012345678901234567890abcd'
204 }
205 }
206 }
207 }
208
209 AfterEach {
210 Clear-MockCIEnvironment
211 }
212
213 Context 'WhatIf behavior' {
214 It 'Does not modify file when WhatIf is specified' {
215 $originalContent = Get-Content $script:TestWorkflow -Raw
216
217 Update-WorkflowFile -FilePath $script:TestWorkflow -WhatIf
218
219 $newContent = Get-Content $script:TestWorkflow -Raw
220 $newContent | Should -Be $originalContent
221 }
222 }
223}
224
225Describe 'Invoke-GitHubAPIWithRetry' -Tag 'Unit' {
226 BeforeEach {
227 Initialize-MockCIEnvironment
228 $env:GITHUB_TOKEN = 'ghp_test123456789'
229 $script:AttemptCount = 0
230
231 # Mock Start-Sleep to avoid actual delays
232 Mock Start-Sleep { }
233 }
234
235 AfterEach {
236 Clear-MockCIEnvironment
237 }
238
239 Context 'Successful requests' {
240 It 'Returns response on first attempt success' {
241 Mock Invoke-RestMethod {
242 $script:AttemptCount++
243 return @{ data = 'success' }
244 }
245
246 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' }
247
248 $result.data | Should -Be 'success'
249 $script:AttemptCount | Should -Be 1
250 Should -Not -Invoke Start-Sleep
251 }
252 }
253
254 Context 'Rate limit retry behavior' {
255 It 'Retries on 403 rate limit error and succeeds' {
256 Mock Invoke-RestMethod {
257 $script:AttemptCount++
258 if ($script:AttemptCount -lt 3) {
259 # Create exception with proper Response.StatusCode for rate limit detection
260 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
261 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
262 throw $exception
263 }
264 return @{ data = 'success after retry' }
265 }
266
267 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -MaxRetries 5
268
269 $result.data | Should -Be 'success after retry'
270 $script:AttemptCount | Should -Be 3
271 Should -Invoke Start-Sleep -Times 2
272 }
273
274 It 'Throws after exceeding MaxRetries' {
275 Mock Invoke-RestMethod {
276 $script:AttemptCount++
277 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
278 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
279 throw $exception
280 }
281
282 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -MaxRetries 2 } |
283 Should -Throw
284
285 $script:AttemptCount | Should -Be 2 # MaxRetries attempts
286 }
287
288 It 'Uses exponential backoff delay' {
289 $script:delays = @()
290 Mock Start-Sleep { param($Seconds) $script:delays += $Seconds }
291 Mock Invoke-RestMethod {
292 $script:AttemptCount++
293 if ($script:AttemptCount -lt 3) {
294 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
295 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
296 throw $exception
297 }
298 return @{ data = 'success' }
299 }
300
301 Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -InitialDelaySeconds 2
302
303 # Verify exponential backoff pattern
304 $script:delays[0] | Should -Be 2 # First delay
305 $script:delays[1] | Should -Be 4 # Second delay (doubled)
306 }
307 }
308
309 Context 'Non-retryable errors' {
310 It 'Throws immediately on non-rate-limit error' {
311 Mock Invoke-RestMethod {
312 $script:AttemptCount++
313 throw [System.Net.WebException]::new('Not Found')
314 }
315
316 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } } |
317 Should -Throw '*Not Found*'
318
319 $script:AttemptCount | Should -Be 1
320 Should -Not -Invoke Start-Sleep
321 }
322 }
323
324 Context 'Request with body' {
325 It 'Includes body in request' {
326 Mock Invoke-RestMethod {
327 param($Uri, $Method, $Headers, $Body, $ContentType)
328 $null = $Uri, $Method, $Headers # Suppress PSScriptAnalyzer unused parameter warnings
329 return @{ received = $Body; contentType = $ContentType }
330 }
331
332 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers @{ Authorization = 'token test' } -Body '{"query":"test"}'
333
334 $result.received | Should -Be '{"query":"test"}'
335 $result.contentType | Should -Be 'application/json'
336 }
337 }
338}
339
340Describe 'Get-LatestCommitSHA' -Tag 'Unit' {
341 BeforeEach {
342 Initialize-MockCIEnvironment
343 $env:GITHUB_TOKEN = 'ghp_test123456789'
344 }
345
346 AfterEach {
347 Clear-MockCIEnvironment
348 }
349
350 Context 'Successful SHA retrieval' {
351 It 'Returns SHA for valid repository and branch' {
352 Mock Invoke-RestMethod {
353 return @{ sha = 'abc123def456789012345678901234567890abcdef' }
354 }
355
356 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'main'
357
358 $result | Should -Be 'abc123def456789012345678901234567890abcdef'
359 }
360
361 It 'Handles branch parameter with refs/heads prefix' {
362 Mock Invoke-RestMethod {
363 param($Uri)
364 if ($Uri -match 'refs/heads/main') {
365 return @{ sha = 'sha123' }
366 }
367 return @{ sha = 'sha456' }
368 }
369
370 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'refs/heads/main'
371
372 $result | Should -Be 'sha123'
373 }
374 }
375
376 Context 'Error handling' {
377 It 'Returns null for non-existent repository' {
378 Mock Invoke-RestMethod {
379 throw [System.Net.WebException]::new('Not Found')
380 }
381
382 $result = Get-LatestCommitSHA -Owner 'nonexistent' -Repo 'repo' -Branch 'main'
383
384 $result | Should -BeNullOrEmpty
385 }
386
387 It 'Returns null on API error without throwing' {
388 Mock Invoke-RestMethod {
389 throw [System.Exception]::new('Network error')
390 }
391
392 # Function should handle error gracefully and return null
393 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'main'
394 $result | Should -BeNullOrEmpty
395 }
396 }
397
398 Context 'Default branch detection' {
399 It 'Uses default branch when Branch not specified' {
400 Mock Invoke-RestMethod {
401 param($Uri)
402 if ($Uri -match '/repos/[^/]+/[^/]+$') {
403 return @{ default_branch = 'main' }
404 }
405 return @{ sha = 'default-branch-sha' }
406 }
407
408 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout'
409
410 $result | Should -Not -BeNullOrEmpty
411 }
412 }
413}
414
415Describe 'Test-GitHubToken' -Tag 'Unit' {
416 BeforeEach {
417 Initialize-MockCIEnvironment
418 }
419
420 AfterEach {
421 Clear-MockCIEnvironment
422 }
423
424 Context 'Valid authenticated token' {
425 It 'Returns Valid and Authenticated for good token' {
426 Mock Invoke-RestMethod {
427 return (script:New-MockGitHubGraphQLResponse -Login 'testuser' -RateRemaining 5000)
428 }
429
430 $result = Test-GitHubToken -Token 'ghp_validtoken123'
431
432 $result.Valid | Should -BeTrue
433 $result.Authenticated | Should -BeTrue
434 $result.User | Should -Be 'testuser'
435 }
436
437 It 'Returns rate limit information' {
438 Mock Invoke-RestMethod {
439 return (script:New-MockGitHubGraphQLResponse -RateRemaining 4500 -RateLimit 5000)
440 }
441
442 $result = Test-GitHubToken -Token 'ghp_validtoken123'
443
444 $result.Remaining | Should -Be 4500
445 $result.RateLimit | Should -Be 5000
446 }
447 }
448
449 Context 'Unauthenticated access' {
450 It 'Returns Valid but not Authenticated for empty token' {
451 Mock Invoke-RestMethod {
452 return @{
453 data = @{
454 rateLimit = @{ remaining = 60; limit = 60 }
455 }
456 }
457 }
458
459 $result = Test-GitHubToken -Token ''
460
461 $result.Valid | Should -BeTrue
462 $result.Authenticated | Should -BeFalse
463 }
464 }
465
466 Context 'Low rate limit warning' {
467 It 'Includes warning when remaining is low' {
468 Mock Invoke-RestMethod {
469 return (script:New-MockGitHubGraphQLResponse -RateRemaining 50 -RateLimit 5000)
470 }
471
472 $result = Test-GitHubToken -Token 'ghp_validtoken123'
473
474 $result.Remaining | Should -BeLessThan 100
475 }
476 }
477
478 Context 'Invalid token' {
479 It 'Returns Valid false on API error' {
480 Mock Invoke-RestMethod {
481 throw [System.Net.WebException]::new('Unauthorized')
482 }
483
484 $result = Test-GitHubToken -Token 'invalid_token'
485
486 $result.Valid | Should -BeFalse
487 }
488
489 It 'Includes error message on failure' {
490 Mock Invoke-RestMethod {
491 throw [System.Exception]::new('Bad credentials')
492 }
493
494 $result = Test-GitHubToken -Token 'bad_token'
495
496 $result.Message | Should -Not -BeNullOrEmpty
497 }
498 }
499}
500
501Describe 'Export-SecurityReport' -Tag 'Unit' {
502 BeforeAll {
503 $script:MockResults = @(
504 @{
505 FilePath = 'workflow1.yml'
506 ActionsPinned = 3
507 ActionsSkipped = 1
508 Changes = @(
509 @{ Action = 'actions/checkout@v4'; Status = 'Pinned' }
510 )
511 }
512 )
513 }
514
515 Context 'Report generation' {
516 It 'Creates report file' {
517 Mock New-Item { param($Path) return @{ FullName = $Path } }
518 Mock Set-Content { }
519 Mock Get-Date { return [datetime]'2026-01-26T10:00:00' }
520
521 $result = Export-SecurityReport -Results $script:MockResults
522
523 $result | Should -Not -BeNullOrEmpty
524 }
525
526 It 'Returns report file path' {
527 Mock New-Item { param($Path) return @{ FullName = $Path } }
528 Mock Set-Content { }
529
530 $result = Export-SecurityReport -Results $script:MockResults
531
532 $result | Should -Match '\.json$'
533 }
534 }
535
536 Context 'Empty results handling' {
537 It 'Rejects empty results array via parameter validation' {
538 { Export-SecurityReport -Results @() } | Should -Throw '*empty collection*'
539 }
540 }
541}
542
543Describe 'Set-ContentPreservePermission' -Tag 'Unit' {
544 Context 'File writing' {
545 It 'Writes content to file' {
546 $testPath = Join-Path $TestDrive 'test-write.txt'
547
548 Set-ContentPreservePermission -Path $testPath -Value 'test content'
549
550 Test-Path $testPath | Should -BeTrue
551 Get-Content $testPath -Raw | Should -Match 'test content'
552 }
553
554 It 'Respects NoNewline parameter' {
555 $testPath = Join-Path $TestDrive 'test-nonewline.txt'
556
557 Set-ContentPreservePermission -Path $testPath -Value 'no newline' -NoNewline
558
559 $content = [System.IO.File]::ReadAllText($testPath)
560 $content | Should -Be 'no newline'
561 }
562 }
563
564 Context 'Permission preservation' {
565 It 'Does not throw on Windows' {
566 $testPath = Join-Path $TestDrive 'test-perm.txt'
567
568 { Set-ContentPreservePermission -Path $testPath -Value 'content' } |
569 Should -Not -Throw
570 }
571 }
572
573 Context 'Overwrite behavior' {
574 It 'Overwrites existing file content' {
575 $testPath = Join-Path $TestDrive 'test-overwrite.txt'
576 Set-Content $testPath -Value 'original'
577
578 Set-ContentPreservePermission -Path $testPath -Value 'updated'
579
580 Get-Content $testPath -Raw | Should -Match 'updated'
581 }
582 }
583}
584
585Describe 'Get-SHAForAction - Already Pinned' -Tag 'Unit' {
586 BeforeAll {
587 $script:OriginalGitHubToken = $env:GITHUB_TOKEN
588 $env:GITHUB_TOKEN = 'ghp_test123456789'
589 }
590
591 AfterAll {
592 $env:GITHUB_TOKEN = $script:OriginalGitHubToken
593 }
594
595 Context 'SHA-pinned action without UpdateStale' {
596 It 'Returns original ref when action is already SHA-pinned' {
597 $sha = 'a' * 40
598 $ref = "actions/checkout@$sha"
599 Mock Write-SecurityLog { }
600
601 $result = Get-SHAForAction -ActionRef $ref
602
603 $result | Should -Be $ref
604 }
605 }
606
607 Context 'SHA-pinned action with UpdateStale' {
608 It 'Returns original ref when UpdateStale is not specified' {
609 $currentSHA = 'a' * 40
610 $latestSHA = 'b' * 40
611 $ref = "actions/checkout@$currentSHA"
612
613 Mock Write-SecurityLog { }
614 Mock Get-LatestCommitSHA { return $latestSHA }
615
616 $result = Get-SHAForAction -ActionRef $ref
617
618 # Without UpdateStale flag in scope, returns original
619 $result | Should -Be $ref
620 }
621 }
622}
623
624Describe 'Update-WorkflowFile - Edge Cases' -Tag 'Unit' {
625 Context 'No actions in file' {
626 It 'Returns zero counts when file has no action references' {
627 $testFile = Join-Path $TestDrive 'empty-workflow.yml'
628 Set-Content $testFile -Value @'
629name: empty
630on: push
631jobs:
632 build:
633 runs-on: ubuntu-latest
634 steps:
635 - run: echo hello
636'@
637 Mock Write-SecurityLog { }
638
639 $result = Update-WorkflowFile -FilePath $testFile
640
641 $result.ActionsProcessed | Should -Be 0
642 $result.ActionsPinned | Should -Be 0
643 $result.ActionsSkipped | Should -Be 0
644 }
645 }
646
647 Context 'File with local actions' {
648 It 'Skips local action references starting with ./' {
649 $testFile = Join-Path $TestDrive 'local-action.yml'
650 Set-Content $testFile -Value @'
651name: local
652on: push
653jobs:
654 build:
655 runs-on: ubuntu-latest
656 steps:
657 - uses: ./local-action
658'@
659 Mock Write-SecurityLog { }
660
661 $result = Update-WorkflowFile -FilePath $testFile
662
663 $result.ActionsProcessed | Should -Be 0
664 }
665 }
666}
667
668Describe 'Invoke-ActionSHAPinningUpdate' -Tag 'Unit' {
669 BeforeAll {
670 $env:GITHUB_TOKEN = 'ghp_test123456789'
671 Initialize-MockCIEnvironment
672 }
673 AfterAll {
674 Clear-MockCIEnvironment
675 }
676
677 Context 'Missing workflow path' {
678 It 'Throws when workflow path does not exist' {
679 { Invoke-ActionSHAPinningUpdate -WorkflowPath '/nonexistent/path' } |
680 Should -Throw '*Workflow path not found*'
681 }
682 }
683
684 Context 'No YAML files in directory' {
685 It 'Warns and returns when no yml files found' {
686 $emptyDir = Join-Path $TestDrive 'empty-workflows'
687 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
688
689 Mock Write-SecurityLog { }
690
691 Invoke-ActionSHAPinningUpdate -WorkflowPath $emptyDir
692
693 Should -Invoke Write-SecurityLog -Times 1 -ParameterFilter { $Level -eq 'Warning' }
694 }
695 }
696
697 Context 'Full orchestration' {
698 It 'Processes workflow files and generates summary' {
699 $workDir = Join-Path $TestDrive 'orchestration-workflows'
700 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
701
702 $sha = 'a' * 40
703 $content = @"
704name: test
705on: push
706jobs:
707 build:
708 runs-on: ubuntu-latest
709 steps:
710 - uses: actions/checkout@$sha
711"@
712 Set-Content (Join-Path $workDir 'ci.yml') -Value $content
713
714 Mock Write-SecurityLog { }
715 Mock Write-SecurityOutput { }
716 Mock Get-SHAForAction { return "actions/checkout@$sha" }
717
718 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console'
719
720 Should -Invoke Write-SecurityOutput -Times 1
721 }
722 }
723
724 Context 'OutputReport flag' {
725 It 'Calls Export-SecurityReport when OutputReport is set' {
726 $workDir = Join-Path $TestDrive 'report-workflows'
727 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
728
729 Set-Content (Join-Path $workDir 'test.yml') -Value @'
730name: test
731on: push
732jobs:
733 build:
734 runs-on: ubuntu-latest
735 steps:
736 - run: echo hi
737'@
738 Mock Write-SecurityLog { }
739 Mock Write-SecurityOutput { }
740 Mock Export-SecurityReport { return (Join-Path $TestDrive 'report.json') }
741
742 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputReport -OutputFormat 'console'
743
744 Should -Invoke Export-SecurityReport -Times 1
745 }
746 }
747
748 Context 'Manual review actions' {
749 It 'Adds SecurityIssue for actions requiring manual review' {
750 $workDir = Join-Path $TestDrive 'manual-review-workflows'
751 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
752
753 Set-Content (Join-Path $workDir 'unmapped.yml') -Value @'
754name: unmapped
755on: push
756jobs:
757 build:
758 runs-on: ubuntu-latest
759 steps:
760 - name: Unknown action
761 uses: some-unknown/action@v1
762'@
763 Mock Write-SecurityLog { }
764 Mock Write-SecurityOutput { }
765 Mock Get-SHAForAction { return $null }
766 Mock New-SecurityIssue { return [PSCustomObject]@{Type='';Severity='';Title='';Description=''} }
767
768 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console'
769
770 Should -Invoke New-SecurityIssue -Times 1
771 }
772 }
773
774 Context 'WhatIf support' {
775 It 'Does not modify files when WhatIf is used' {
776 $workDir = Join-Path $TestDrive 'whatif-workflows'
777 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
778
779 $sha = 'a' * 40
780 $content = @"
781name: whatif
782on: push
783jobs:
784 build:
785 runs-on: ubuntu-latest
786 steps:
787 - uses: actions/checkout@$sha
788"@
789 $filePath = Join-Path $workDir 'whatif.yml'
790 Set-Content $filePath -Value $content
791
792 Mock Write-SecurityLog { }
793 Mock Write-SecurityOutput { }
794 Mock Get-SHAForAction { return "actions/checkout@$sha" }
795
796 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console' -WhatIf
797
798 # File content should remain unchanged
799 $afterContent = Get-Content $filePath -Raw
800 $afterContent | Should -Match "actions/checkout@$sha"
801 }
802 }
803}
804