microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
7956ca698dfb9e6612525bbda2dd0cd22c7c8fe9

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Modules/SecurityHelpers.psm1

537lines · 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 Test-GitHubToken {
326 <#
327 .SYNOPSIS
328 Validates a GitHub token and retrieves rate limit information.
329
330 .DESCRIPTION
331 Tests that a GitHub token is valid by making an API call to the
332 rate_limit endpoint. Returns authentication status and rate limit details.
333
334 .PARAMETER Token
335 The GitHub token to validate.
336
337 .OUTPUTS
338 [hashtable] with keys: IsValid, RateLimit, Remaining, ResetTime, Message
339
340 .EXAMPLE
341 $result = Test-GitHubToken -Token $env:GITHUB_TOKEN
342 if ($result.IsValid) { Write-Host "Token is valid, $($result.Remaining) requests remaining" }
343 #>
344 [CmdletBinding()]
345 [OutputType([hashtable])]
346 param(
347 [Parameter(Mandatory)]
348 [AllowEmptyString()]
349 [string]$Token
350 )
351
352 $result = @{
353 IsValid = $false
354 RateLimit = 0
355 Remaining = 0
356 ResetTime = $null
357 Message = ''
358 }
359
360 if ([string]::IsNullOrEmpty($Token)) {
361 $result.Message = 'Token is empty or null'
362 return $result
363 }
364
365 try {
366 $headers = @{
367 Authorization = "Bearer $Token"
368 Accept = 'application/vnd.github+json'
369 'User-Agent' = 'SecurityHelpers-PowerShell/1.0'
370 'X-GitHub-Api-Version' = '2022-11-28'
371 }
372
373 $response = Invoke-RestMethod -Uri 'https://api.github.com/rate_limit' `
374 -Headers $headers `
375 -Method Get `
376 -ErrorAction Stop
377
378 $result.IsValid = $true
379 $result.RateLimit = $response.rate.limit
380 $result.Remaining = $response.rate.remaining
381 $result.ResetTime = [DateTimeOffset]::FromUnixTimeSeconds($response.rate.reset).DateTime
382 $result.Message = 'Token validated successfully'
383 }
384 catch {
385 $result.Message = "Token validation failed: $($_.Exception.Message)"
386 $statusCode = $null
387 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
388 $statusCode = [int]$_.Exception.Response.StatusCode
389 }
390 if ($statusCode -eq 401) {
391 $result.Message = 'Token is invalid or expired'
392 }
393 elseif ($statusCode -eq 403) {
394 $result.Message = 'Token lacks required permissions or rate limit exceeded'
395 }
396 }
397
398 return $result
399}
400
401function Invoke-GitHubAPIWithRetry {
402 <#
403 .SYNOPSIS
404 Invokes a GitHub API call with automatic retry on rate limits.
405
406 .DESCRIPTION
407 Makes HTTP requests to the GitHub API with exponential backoff retry
408 logic for handling rate limit (429) and server error (5xx) responses.
409
410 .PARAMETER Uri
411 The GitHub API endpoint URI.
412
413 .PARAMETER Method
414 HTTP method: GET, POST, PUT, PATCH, DELETE.
415
416 .PARAMETER Headers
417 Hashtable of HTTP headers including Authorization.
418
419 .PARAMETER Body
420 Request body for POST/PUT/PATCH requests.
421
422 .PARAMETER MaxRetries
423 Maximum number of retry attempts. Default: 3.
424
425 .PARAMETER InitialDelaySeconds
426 Initial delay between retries in seconds. Default: 2.
427
428 .OUTPUTS
429 API response object or $null on failure.
430
431 .EXAMPLE
432 $headers = @{ Authorization = "Bearer $token"; Accept = 'application/vnd.github+json' }
433 $response = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/repos/owner/repo/commits' -Headers $headers
434 #>
435 [CmdletBinding()]
436 param(
437 [Parameter(Mandatory)]
438 [string]$Uri,
439
440 [Parameter()]
441 [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
442 [string]$Method = 'GET',
443
444 [Parameter(Mandatory)]
445 [hashtable]$Headers,
446
447 [Parameter()]
448 [string]$Body,
449
450 [Parameter()]
451 [ValidateRange(1, 10)]
452 [int]$MaxRetries = 3,
453
454 [Parameter()]
455 [ValidateRange(1, 60)]
456 [int]$InitialDelaySeconds = 2
457 )
458
459 $attempt = 0
460 $delay = $InitialDelaySeconds
461
462 while ($attempt -lt $MaxRetries) {
463 $attempt++
464 try {
465 $params = @{
466 Uri = $Uri
467 Method = $Method
468 Headers = $Headers
469 ErrorAction = 'Stop'
470 }
471
472 if ($Body) {
473 $params['Body'] = $Body
474 $params['ContentType'] = 'application/json'
475 }
476
477 $response = Invoke-RestMethod @params
478 return $response
479 }
480 catch {
481 $statusCode = $null
482 # Try multiple methods to extract HTTP status code (cross-platform compatibility)
483 # Method 1: Direct StatusCode property access
484 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
485 # StatusCode might be an enum - try value__ first, then direct cast
486 $statusCode = $_.Exception.Response.StatusCode.value__ -as [int]
487 if (-not $statusCode) {
488 $statusCode = $_.Exception.Response.StatusCode -as [int]
489 }
490 }
491 # Method 2: Parse status code from exception message (e.g., "404 (Not Found)" or "Response status code does not indicate success: 429")
492 if (-not $statusCode -and $_.Exception.Message -match '\b([45]\d{2})\b') {
493 $statusCode = [int]$Matches[1]
494 }
495 # Method 3: Map common HTTP status text to codes
496 if (-not $statusCode) {
497 $messageUpper = $_.Exception.Message.ToUpper()
498 if ($messageUpper -match 'UNAUTHORIZED') { $statusCode = 401 }
499 elseif ($messageUpper -match 'NOT\s*FOUND') { $statusCode = 404 }
500 elseif ($messageUpper -match 'TOO\s*MANY\s*REQUESTS|RATE\s*LIMIT') { $statusCode = 429 }
501 elseif ($messageUpper -match 'FORBIDDEN') { $statusCode = 403 }
502 elseif ($messageUpper -match 'SERVER\s*ERROR|INTERNAL\s*SERVER') { $statusCode = 500 }
503 elseif ($messageUpper -match 'BAD\s*GATEWAY') { $statusCode = 502 }
504 elseif ($messageUpper -match 'SERVICE\s*UNAVAILABLE') { $statusCode = 503 }
505 elseif ($messageUpper -match 'GATEWAY\s*TIMEOUT') { $statusCode = 504 }
506 }
507
508 # Check if it's a rate limit error (403 or 429) or server error (5xx)
509 $isRetryable = $statusCode -in 403, 429 -or ($statusCode -ge 500 -and $statusCode -lt 600)
510
511 if ($isRetryable -and $attempt -lt $MaxRetries) {
512 Write-Warning "GitHub API request failed (HTTP $statusCode). Retrying in $delay seconds (attempt $attempt/$MaxRetries)..."
513 Start-Sleep -Seconds $delay
514 $delay = $delay * 2 # Exponential backoff
515 }
516 else {
517 if ($attempt -ge $MaxRetries -and $isRetryable) {
518 Write-Error "GitHub API request failed after $MaxRetries attempts: $($_.Exception.Message)" -ErrorAction Continue
519 }
520 else {
521 Write-Error "GitHub API request failed: $($_.Exception.Message)" -ErrorAction Continue
522 }
523 return $null
524 }
525 }
526 }
527
528 return $null
529}
530
531Export-ModuleMember -Function @(
532 'Write-SecurityLog'
533 'New-SecurityIssue'
534 'Write-SecurityReport'
535 'Test-GitHubToken'
536 'Invoke-GitHubAPIWithRetry'
537)
538