microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3cade0d7dd510bcfa350a1dc9512159a1f60ae4f

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Modules/SecurityHelpers.psm1

625lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3# Licensed under the MIT license.
4
5# SecurityHelpers.psm1
6#
7# Purpose: Shared security utility functions for hve-core security scripts.
8# Author: HVE Core Team
9
10#Requires -Version 7.0
11
12# Omit -Force so the standalone CIHelpers export is not shadowed by a nested re-import.
13Import-Module (Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1')
14
15function Write-SecurityLog {
16 <#
17 .SYNOPSIS
18 Writes a timestamped log entry with severity level.
19
20 .DESCRIPTION
21 Outputs formatted log messages to console with color coding
22 and optionally to a log file.
23
24 .PARAMETER Message
25 Log message text. Empty/whitespace messages output a blank line.
26
27 .PARAMETER Level
28 Severity level: Info, Warning, Error, Success, Debug, Verbose.
29
30 .PARAMETER LogPath
31 Optional file path for persistent logging.
32
33 .PARAMETER OutputFormat
34 Controls console output. 'console' enables colored output.
35
36 .EXAMPLE
37 Write-SecurityLog -Message "Scanning workflows" -Level Info
38
39 .PARAMETER CIAnnotation
40 When set, forwards Warning and Error messages as CI annotations via Write-CIAnnotation.
41
42 .EXAMPLE
43 Write-SecurityLog -Message "Stale SHA detected" -Level Warning -LogPath "./logs/security.log"
44
45 .EXAMPLE
46 Write-SecurityLog -Message "Not pinned" -Level Warning -CIAnnotation
47 #>
48 [CmdletBinding()]
49 param(
50 [Parameter(Mandatory)]
51 [AllowEmptyString()]
52 [string]$Message,
53
54 [Parameter()]
55 [ValidateSet('Info', 'Warning', 'Error', 'Success', 'Debug', 'Verbose')]
56 [string]$Level = 'Info',
57
58 [Parameter()]
59 [string]$LogPath,
60
61 [Parameter()]
62 [string]$OutputFormat = 'console',
63
64 [Parameter()]
65 [switch]$CIAnnotation
66 )
67
68 # Handle blank line requests
69 if ([string]::IsNullOrWhiteSpace($Message)) {
70 if ($OutputFormat -eq 'console') {
71 Write-Host ''
72 }
73 return
74 }
75
76 $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
77 $logEntry = "[$timestamp] [$Level] $Message"
78
79 # Console output with colors
80 if ($OutputFormat -eq 'console') {
81 $color = switch ($Level) {
82 'Info' { 'Cyan' }
83 'Warning' { 'Yellow' }
84 'Error' { 'Red' }
85 'Success' { 'Green' }
86 'Debug' { 'Gray' }
87 'Verbose' { 'Cyan' }
88 }
89 Write-Host $logEntry -ForegroundColor $color
90 }
91
92 # Forward warnings and errors as CI annotations
93 if ($CIAnnotation -and ($Level -eq 'Warning' -or $Level -eq 'Error')) {
94 Write-CIAnnotation -Message $Message -Level $Level
95 }
96
97 # File logging if path provided
98 if ($LogPath) {
99 try {
100 $logDir = Split-Path -Parent $LogPath
101 if ($logDir -and -not (Test-Path $logDir)) {
102 New-Item -ItemType Directory -Path $logDir -Force | Out-Null
103 }
104 Add-Content -Path $LogPath -Value $logEntry -ErrorAction Stop
105 }
106 catch {
107 Write-Warning "Failed to write to log file: $($_.Exception.Message)"
108 }
109 }
110}
111
112function New-SecurityIssue {
113 <#
114 .SYNOPSIS
115 Creates a structured security issue object.
116
117 .DESCRIPTION
118 Returns a PSCustomObject representing a security finding with
119 type, severity, location, and remediation information.
120
121 .PARAMETER Type
122 Category of security issue (e.g., 'UnpinnedAction', 'StaleSHA').
123
124 .PARAMETER Severity
125 Impact level: Low, Medium, High, Critical.
126
127 .PARAMETER Title
128 Brief issue title.
129
130 .PARAMETER Description
131 Detailed description of the issue.
132
133 .PARAMETER File
134 Source file where issue was found.
135
136 .PARAMETER Line
137 Line number in source file.
138
139 .PARAMETER Recommendation
140 Suggested remediation action.
141
142 .EXAMPLE
143 $issue = New-SecurityIssue -Type 'UnpinnedAction' -Severity 'High' -Title 'Action not pinned' -Description 'uses: actions/checkout@v4' -File '.github/workflows/ci.yml' -Line 15
144 #>
145 [CmdletBinding()]
146 [OutputType([PSCustomObject])]
147 param(
148 [Parameter(Mandatory)]
149 [string]$Type,
150
151 [Parameter(Mandatory)]
152 [ValidateSet('Low', 'Medium', 'High', 'Critical')]
153 [string]$Severity,
154
155 [Parameter(Mandatory)]
156 [string]$Title,
157
158 [Parameter(Mandatory)]
159 [string]$Description,
160
161 [Parameter()]
162 [string]$File,
163
164 [Parameter()]
165 [int]$Line = 0,
166
167 [Parameter()]
168 [string]$Recommendation
169 )
170
171 return [PSCustomObject]@{
172 Type = $Type
173 Severity = $Severity
174 Title = $Title
175 Description = $Description
176 File = $File
177 Line = $Line
178 Recommendation = $Recommendation
179 Timestamp = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
180 }
181}
182
183function Write-SecurityReport {
184 <#
185 .SYNOPSIS
186 Outputs security scan results in the specified format.
187
188 .DESCRIPTION
189 Formats and outputs an array of security issues as JSON, console output,
190 or markdown table. Optionally writes to a file.
191
192 .PARAMETER Results
193 Array of security issue objects from New-SecurityIssue.
194
195 .PARAMETER Summary
196 Summary text for the report header.
197
198 .PARAMETER OutputFormat
199 Output format: json, console, or markdown.
200
201 .PARAMETER OutputPath
202 File path to write results. If not specified, returns output.
203
204 .EXAMPLE
205 Write-SecurityReport -Results $issues -OutputFormat json -OutputPath './logs/security.json'
206
207 .EXAMPLE
208 Write-SecurityReport -Results $issues -Summary "Found 3 issues" -OutputFormat console
209 #>
210 [CmdletBinding()]
211 param(
212 [Parameter()]
213 [array]$Results = @(),
214
215 [Parameter()]
216 [string]$Summary = '',
217
218 [Parameter(Mandatory)]
219 [ValidateSet('json', 'console', 'markdown')]
220 [string]$OutputFormat,
221
222 [Parameter()]
223 [string]$OutputPath
224 )
225
226 switch ($OutputFormat) {
227 'json' {
228 $output = @{
229 Summary = $Summary
230 Issues = $Results
231 Timestamp = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
232 Count = @($Results).Count
233 }
234 $jsonOutput = $output | ConvertTo-Json -Depth 5
235
236 if ($OutputPath) {
237 $outputDir = Split-Path -Parent $OutputPath
238 if ($outputDir -and -not (Test-Path $outputDir)) {
239 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
240 }
241 Set-Content -Path $OutputPath -Value $jsonOutput
242 Write-SecurityLog -Message "JSON security report written to: $OutputPath" -Level Success
243 }
244 return $jsonOutput
245 }
246 'console' {
247 if (@($Results).Count -eq 0) {
248 Write-SecurityLog -Message 'No security issues found' -Level Success
249 if ($Summary) {
250 Write-SecurityLog -Message $Summary -Level Info
251 }
252 return
253 }
254
255 Write-SecurityLog -Message '=== SECURITY ISSUES DETECTED ===' -Level Warning
256 if ($Summary) {
257 Write-SecurityLog -Message $Summary -Level Info
258 }
259
260 foreach ($issue in $Results) {
261 Write-SecurityLog -Message "[$($issue.Severity)] $($issue.Type): $($issue.Title)" -Level Warning
262 Write-SecurityLog -Message " Description: $($issue.Description)" -Level Info
263 if ($issue.File) {
264 $location = $issue.File
265 if ($issue.Line -gt 0) {
266 $location += ":$($issue.Line)"
267 }
268 Write-SecurityLog -Message " Location: $location" -Level Info
269 }
270 if ($issue.Recommendation) {
271 Write-SecurityLog -Message " Recommendation: $($issue.Recommendation)" -Level Info
272 }
273 Write-SecurityLog -Message '' -Level Info
274 }
275
276 Write-SecurityLog -Message "Total issues: $(@($Results).Count)" -Level Warning
277 return
278 }
279 'markdown' {
280 $md = @()
281
282 if (@($Results).Count -eq 0) {
283 $md += '## Security Scan Results'
284 $md += ''
285 $md += ':white_check_mark: No security issues found.'
286 if ($Summary) {
287 $md += ''
288 $md += $Summary
289 }
290 }
291 else {
292 $md += '## Security Scan Results'
293 $md += ''
294 if ($Summary) {
295 $md += $Summary
296 $md += ''
297 }
298 $md += "**Total issues: $(@($Results).Count)**"
299 $md += ''
300 $md += '| Severity | Type | Title | File | Line |'
301 $md += '|----------|------|-------|------|------|'
302
303 foreach ($issue in $Results) {
304 $file = if ($issue.File) { $issue.File } else { '-' }
305 $line = if ($issue.Line -gt 0) { $issue.Line } else { '-' }
306 $md += "| $($issue.Severity) | $($issue.Type) | $($issue.Title) | $file | $line |"
307 }
308 }
309
310 $content = $md -join "`n"
311
312 if ($OutputPath) {
313 $outputDir = Split-Path -Parent $OutputPath
314 if ($outputDir -and -not (Test-Path $outputDir)) {
315 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
316 }
317 Set-Content -Path $OutputPath -Value $content
318 Write-SecurityLog -Message "Markdown report written to: $OutputPath" -Level Success
319 }
320 return $content
321 }
322 }
323}
324
325function Get-GitHubApiBase {
326 <#
327 .SYNOPSIS
328 Returns the GitHub API base URL, respecting HVE_GITHUB_API_URL.
329
330 .OUTPUTS
331 [string] The API base URL without a trailing slash.
332
333 .EXAMPLE
334 $apiBase = Get-GitHubApiBase
335 #>
336 [CmdletBinding()]
337 [OutputType([string])]
338 param()
339
340 if ($env:HVE_GITHUB_API_URL) { return $env:HVE_GITHUB_API_URL }
341 return 'https://api.github.com'
342}
343
344function Test-GitHubToken {
345 <#
346 .SYNOPSIS
347 Validates a GitHub token and retrieves rate limit information.
348
349 .DESCRIPTION
350 Tests that a GitHub token is valid by querying the GitHub GraphQL API
351 for the authenticated viewer and rate limit details.
352
353 .PARAMETER Token
354 The GitHub token to validate.
355
356 .OUTPUTS
357 [hashtable] with keys: Valid, Authenticated, RateLimit, Remaining, ResetAt, User, Message
358
359 .EXAMPLE
360 $result = Test-GitHubToken -Token $env:GITHUB_TOKEN
361 if ($result.Valid) { Write-Host "Token is valid, $($result.Remaining) requests remaining" }
362 #>
363 [CmdletBinding()]
364 [OutputType([hashtable])]
365 param(
366 [Parameter(Mandatory)]
367 [AllowEmptyString()]
368 [string]$Token
369 )
370
371 $result = @{
372 Valid = $false
373 Authenticated = $false
374 RateLimit = 0
375 Remaining = 0
376 ResetAt = $null
377 User = $null
378 Message = ''
379 }
380
381 if ([string]::IsNullOrEmpty($Token)) {
382 $result.Message = 'Token is empty or null'
383 return $result
384 }
385
386 try {
387 $headers = @{
388 Authorization = "Bearer $Token"
389 Accept = 'application/vnd.github+json'
390 'User-Agent' = 'SecurityHelpers-PowerShell/1.0'
391 'X-GitHub-Api-Version' = '2022-11-28'
392 }
393
394 $query = @{
395 query = 'query { viewer { login } rateLimit { limit remaining resetAt } }'
396 } | ConvertTo-Json
397
398 $apiBase = Get-GitHubApiBase
399 $response = Invoke-RestMethod -Uri "$apiBase/graphql" -Method Post -Headers $headers -Body $query -ErrorAction Stop
400
401 $data = $null
402 if ($response -is [hashtable]) {
403 $data = $response['data']
404 }
405 elseif ($response.PSObject.Properties.Name -contains 'data') {
406 $data = $response.data
407 }
408
409 $viewer = $null
410 $rateLimit = $null
411 if ($data) {
412 if ($data -is [hashtable]) {
413 $viewer = $data['viewer']
414 $rateLimit = $data['rateLimit']
415 }
416 else {
417 if ($data.PSObject.Properties.Name -contains 'viewer') {
418 $viewer = $data.viewer
419 }
420 if ($data.PSObject.Properties.Name -contains 'rateLimit') {
421 $rateLimit = $data.rateLimit
422 }
423 }
424 }
425
426 if ($viewer) {
427 $result.Valid = $true
428 $result.Authenticated = $true
429 if ($viewer -is [hashtable]) {
430 $result.User = $viewer['login']
431 }
432 elseif ($viewer.PSObject.Properties.Name -contains 'login') {
433 $result.User = $viewer.login
434 }
435 $result.Message = "Authenticated as $($result.User)"
436 }
437 elseif ($rateLimit) {
438 $result.Valid = $true
439 $result.Authenticated = $false
440 $result.Message = 'Unauthenticated access - limited rate limits'
441 }
442
443 if ($rateLimit) {
444 if ($rateLimit -is [hashtable]) {
445 $result.RateLimit = $rateLimit['limit']
446 $result.Remaining = $rateLimit['remaining']
447 $result.ResetAt = $rateLimit['resetAt']
448 }
449 else {
450 if ($rateLimit.PSObject.Properties.Name -contains 'limit') {
451 $result.RateLimit = $rateLimit.limit
452 }
453 if ($rateLimit.PSObject.Properties.Name -contains 'remaining') {
454 $result.Remaining = $rateLimit.remaining
455 }
456 if ($rateLimit.PSObject.Properties.Name -contains 'resetAt') {
457 $result.ResetAt = $rateLimit.resetAt
458 }
459 }
460 }
461
462 if ($result.Remaining -lt 100 -and $result.Valid) {
463 $result.Message += " | WARNING: Only $($result.Remaining) API calls remaining (resets at $($result.ResetAt))"
464 }
465
466 if (-not $result.Authenticated -and $result.Valid) {
467 Write-Warning 'Unauthenticated GitHub GraphQL API requests are heavily rate limited'
468 }
469 }
470 catch {
471 $result.Message = "Token validation failed: $($_.Exception.Message)"
472 $statusCode = $null
473 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
474 $statusCode = [int]$_.Exception.Response.StatusCode
475 }
476 if ($statusCode -eq 401) {
477 $result.Message = 'Token is invalid or expired'
478 }
479 elseif ($statusCode -eq 403) {
480 $result.Message = 'Token lacks required permissions or rate limit exceeded'
481 }
482 }
483
484 return $result
485}
486
487function Invoke-GitHubAPIWithRetry {
488 <#
489 .SYNOPSIS
490 Invokes a GitHub API call with automatic retry on rate limits.
491
492 .DESCRIPTION
493 Makes HTTP requests to the GitHub API with exponential backoff retry
494 logic for handling rate limit (429) and server error (5xx) responses.
495
496 .PARAMETER Uri
497 The GitHub API endpoint URI.
498
499 .PARAMETER Method
500 HTTP method: GET, POST, PUT, PATCH, DELETE.
501
502 .PARAMETER Headers
503 Hashtable of HTTP headers including Authorization.
504
505 .PARAMETER Body
506 Request body for POST/PUT/PATCH requests.
507
508 .PARAMETER MaxRetries
509 Maximum number of retry attempts. Default: 3.
510
511 .PARAMETER InitialDelaySeconds
512 Initial delay between retries in seconds. Default: 2.
513
514 .OUTPUTS
515 API response object or $null on failure.
516
517 .EXAMPLE
518 $headers = @{ Authorization = "Bearer $token"; Accept = 'application/vnd.github+json' }
519 $apiBase = Get-GitHubApiBase
520 $response = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/owner/repo/commits" -Headers $headers
521 #>
522 [CmdletBinding()]
523 param(
524 [Parameter(Mandatory)]
525 [string]$Uri,
526
527 [Parameter()]
528 [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
529 [string]$Method = 'GET',
530
531 [Parameter(Mandatory)]
532 [hashtable]$Headers,
533
534 [Parameter()]
535 [string]$Body,
536
537 [Parameter()]
538 [ValidateRange(1, 10)]
539 [int]$MaxRetries = 3,
540
541 [Parameter()]
542 [ValidateRange(1, 60)]
543 [int]$InitialDelaySeconds = 2
544 )
545
546 $attempt = 0
547 $delay = $InitialDelaySeconds
548
549 while ($attempt -lt $MaxRetries) {
550 $attempt++
551 try {
552 $params = @{
553 Uri = $Uri
554 Method = $Method
555 Headers = $Headers
556 ErrorAction = 'Stop'
557 }
558
559 if ($Body) {
560 $params['Body'] = $Body
561 $params['ContentType'] = 'application/json'
562 }
563
564 $response = Invoke-RestMethod @params
565 return $response
566 }
567 catch {
568 $statusCode = $null
569 # Try multiple methods to extract HTTP status code (cross-platform compatibility)
570 # Method 1: Direct StatusCode property access
571 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
572 # StatusCode might be an enum - try value__ first, then direct cast
573 $statusCode = $_.Exception.Response.StatusCode.value__ -as [int]
574 if (-not $statusCode) {
575 $statusCode = $_.Exception.Response.StatusCode -as [int]
576 }
577 }
578 # Method 2: Parse status code from exception message (e.g., "404 (Not Found)" or "Response status code does not indicate success: 429")
579 if (-not $statusCode -and $_.Exception.Message -match '\b([45]\d{2})\b') {
580 $statusCode = [int]$Matches[1]
581 }
582 # Method 3: Map common HTTP status text to codes
583 if (-not $statusCode) {
584 $messageUpper = $_.Exception.Message.ToUpper()
585 if ($messageUpper -match 'UNAUTHORIZED') { $statusCode = 401 }
586 elseif ($messageUpper -match 'NOT\s*FOUND') { $statusCode = 404 }
587 elseif ($messageUpper -match 'TOO\s*MANY\s*REQUESTS|RATE\s*LIMIT') { $statusCode = 429 }
588 elseif ($messageUpper -match 'FORBIDDEN') { $statusCode = 403 }
589 elseif ($messageUpper -match 'SERVER\s*ERROR|INTERNAL\s*SERVER') { $statusCode = 500 }
590 elseif ($messageUpper -match 'BAD\s*GATEWAY') { $statusCode = 502 }
591 elseif ($messageUpper -match 'SERVICE\s*UNAVAILABLE') { $statusCode = 503 }
592 elseif ($messageUpper -match 'GATEWAY\s*TIMEOUT') { $statusCode = 504 }
593 }
594
595 # Check if it's a rate limit error (403 or 429) or server error (5xx)
596 $isRetryable = $statusCode -in 403, 429 -or ($statusCode -ge 500 -and $statusCode -lt 600)
597
598 if ($isRetryable -and $attempt -lt $MaxRetries) {
599 Write-Warning "GitHub API request failed (HTTP $statusCode). Retrying in $delay seconds (attempt $attempt/$MaxRetries)..."
600 Start-Sleep -Seconds $delay
601 $delay = $delay * 2 # Exponential backoff
602 }
603 else {
604 if ($attempt -ge $MaxRetries -and $isRetryable) {
605 Write-Error "GitHub API request failed after $MaxRetries attempts: $($_.Exception.Message)" -ErrorAction Continue
606 }
607 else {
608 Write-Error "GitHub API request failed: $($_.Exception.Message)" -ErrorAction Continue
609 }
610 return $null
611 }
612 }
613 }
614
615 return $null
616}
617
618Export-ModuleMember -Function @(
619 'Write-SecurityLog'
620 'New-SecurityIssue'
621 'Write-SecurityReport'
622 'Get-GitHubApiBase'
623 'Test-GitHubToken'
624 'Invoke-GitHubAPIWithRetry'
625)
626