microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/dependabot-uuid-postcss-overrides

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Modules/SecurityHelpers.psm1

655lines · 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-StandardTimestamp
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-StandardTimestamp
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-StandardTimestamp
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 Get-PSGalleryApiBase {
345 <#
346 .SYNOPSIS
347 Returns the PowerShell Gallery API base URL, respecting HVE_PSGALLERY_REPOSITORY.
348
349 .DESCRIPTION
350 Returns the OData v2 API base URL used for PowerShell Gallery queries
351 (for example, package metadata lookups in staleness checks). When the
352 HVE_PSGALLERY_REPOSITORY environment variable is set, its value is
353 returned to support offline mirrors and test doubles.
354
355 .OUTPUTS
356 [string] The API base URL without a trailing slash.
357
358 .EXAMPLE
359 $apiBase = Get-PSGalleryApiBase
360
361 .NOTES
362 Mirrors the shape of Get-GitHubApiBase. Callers append OData query
363 paths (for example, "/Packages()?`$filter=...") directly to the result.
364 #>
365 [CmdletBinding()]
366 [OutputType([string])]
367 param()
368
369 if ($env:HVE_PSGALLERY_REPOSITORY) { return $env:HVE_PSGALLERY_REPOSITORY }
370 return 'https://www.powershellgallery.com/api/v2'
371}
372
373function Test-GitHubToken {
374 <#
375 .SYNOPSIS
376 Validates a GitHub token and retrieves rate limit information.
377
378 .DESCRIPTION
379 Tests that a GitHub token is valid by querying the GitHub GraphQL API
380 for the authenticated viewer and rate limit details.
381
382 .PARAMETER Token
383 The GitHub token to validate.
384
385 .OUTPUTS
386 [hashtable] with keys: Valid, Authenticated, RateLimit, Remaining, ResetAt, User, Message
387
388 .EXAMPLE
389 $result = Test-GitHubToken -Token $env:GITHUB_TOKEN
390 if ($result.Valid) { Write-Host "Token is valid, $($result.Remaining) requests remaining" }
391 #>
392 [CmdletBinding()]
393 [OutputType([hashtable])]
394 param(
395 [Parameter(Mandatory)]
396 [AllowEmptyString()]
397 [string]$Token
398 )
399
400 $result = @{
401 Valid = $false
402 Authenticated = $false
403 RateLimit = 0
404 Remaining = 0
405 ResetAt = $null
406 User = $null
407 Message = ''
408 }
409
410 if ([string]::IsNullOrEmpty($Token)) {
411 $result.Message = 'Token is empty or null'
412 return $result
413 }
414
415 try {
416 $headers = @{
417 Authorization = "Bearer $Token"
418 Accept = 'application/vnd.github+json'
419 'User-Agent' = 'SecurityHelpers-PowerShell/1.0'
420 'X-GitHub-Api-Version' = '2022-11-28'
421 }
422
423 $query = @{
424 query = 'query { viewer { login } rateLimit { limit remaining resetAt } }'
425 } | ConvertTo-Json
426
427 $apiBase = Get-GitHubApiBase
428 $response = Invoke-RestMethod -Uri "$apiBase/graphql" -Method Post -Headers $headers -Body $query -ErrorAction Stop
429
430 $data = $null
431 if ($response -is [hashtable]) {
432 $data = $response['data']
433 }
434 elseif ($response.PSObject.Properties.Name -contains 'data') {
435 $data = $response.data
436 }
437
438 $viewer = $null
439 $rateLimit = $null
440 if ($data) {
441 if ($data -is [hashtable]) {
442 $viewer = $data['viewer']
443 $rateLimit = $data['rateLimit']
444 }
445 else {
446 if ($data.PSObject.Properties.Name -contains 'viewer') {
447 $viewer = $data.viewer
448 }
449 if ($data.PSObject.Properties.Name -contains 'rateLimit') {
450 $rateLimit = $data.rateLimit
451 }
452 }
453 }
454
455 if ($viewer) {
456 $result.Valid = $true
457 $result.Authenticated = $true
458 if ($viewer -is [hashtable]) {
459 $result.User = $viewer['login']
460 }
461 elseif ($viewer.PSObject.Properties.Name -contains 'login') {
462 $result.User = $viewer.login
463 }
464 $result.Message = "Authenticated as $($result.User)"
465 }
466 elseif ($rateLimit) {
467 $result.Valid = $true
468 $result.Authenticated = $false
469 $result.Message = 'Unauthenticated access - limited rate limits'
470 }
471
472 if ($rateLimit) {
473 if ($rateLimit -is [hashtable]) {
474 $result.RateLimit = $rateLimit['limit']
475 $result.Remaining = $rateLimit['remaining']
476 $result.ResetAt = $rateLimit['resetAt']
477 }
478 else {
479 if ($rateLimit.PSObject.Properties.Name -contains 'limit') {
480 $result.RateLimit = $rateLimit.limit
481 }
482 if ($rateLimit.PSObject.Properties.Name -contains 'remaining') {
483 $result.Remaining = $rateLimit.remaining
484 }
485 if ($rateLimit.PSObject.Properties.Name -contains 'resetAt') {
486 $result.ResetAt = $rateLimit.resetAt
487 }
488 }
489 }
490
491 if ($result.Remaining -lt 100 -and $result.Valid) {
492 $result.Message += " | WARNING: Only $($result.Remaining) API calls remaining (resets at $($result.ResetAt))"
493 }
494
495 if (-not $result.Authenticated -and $result.Valid) {
496 Write-Warning 'Unauthenticated GitHub GraphQL API requests are heavily rate limited'
497 }
498 }
499 catch {
500 $result.Message = "Token validation failed: $($_.Exception.Message)"
501 $statusCode = $null
502 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
503 $statusCode = [int]$_.Exception.Response.StatusCode
504 }
505 if ($statusCode -eq 401) {
506 $result.Message = 'Token is invalid or expired'
507 }
508 elseif ($statusCode -eq 403) {
509 $result.Message = 'Token lacks required permissions or rate limit exceeded'
510 }
511 }
512
513 return $result
514}
515
516function Invoke-GitHubAPIWithRetry {
517 <#
518 .SYNOPSIS
519 Invokes a GitHub API call with automatic retry on rate limits.
520
521 .DESCRIPTION
522 Makes HTTP requests to the GitHub API with exponential backoff retry
523 logic for handling rate limit (429) and server error (5xx) responses.
524
525 .PARAMETER Uri
526 The GitHub API endpoint URI.
527
528 .PARAMETER Method
529 HTTP method: GET, POST, PUT, PATCH, DELETE.
530
531 .PARAMETER Headers
532 Hashtable of HTTP headers including Authorization.
533
534 .PARAMETER Body
535 Request body for POST/PUT/PATCH requests.
536
537 .PARAMETER MaxRetries
538 Maximum number of retry attempts. Default: 3.
539
540 .PARAMETER InitialDelaySeconds
541 Initial delay between retries in seconds. Default: 2.
542
543 .OUTPUTS
544 API response object or $null on failure.
545
546 .EXAMPLE
547 $headers = @{ Authorization = "Bearer $token"; Accept = 'application/vnd.github+json' }
548 $apiBase = Get-GitHubApiBase
549 $response = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/owner/repo/commits" -Headers $headers
550 #>
551 [CmdletBinding()]
552 param(
553 [Parameter(Mandatory)]
554 [string]$Uri,
555
556 [Parameter()]
557 [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
558 [string]$Method = 'GET',
559
560 [Parameter(Mandatory)]
561 [hashtable]$Headers,
562
563 [Parameter()]
564 [string]$Body,
565
566 [Parameter()]
567 [ValidateRange(1, 10)]
568 [int]$MaxRetries = 3,
569
570 [Parameter()]
571 [ValidateRange(1, 60)]
572 [int]$InitialDelaySeconds = 2
573 )
574
575 $attempt = 0
576 $delay = $InitialDelaySeconds
577
578 while ($attempt -lt $MaxRetries) {
579 $attempt++
580 try {
581 $params = @{
582 Uri = $Uri
583 Method = $Method
584 Headers = $Headers
585 ErrorAction = 'Stop'
586 }
587
588 if ($Body) {
589 $params['Body'] = $Body
590 $params['ContentType'] = 'application/json'
591 }
592
593 $response = Invoke-RestMethod @params
594 return $response
595 }
596 catch {
597 $statusCode = $null
598 # Try multiple methods to extract HTTP status code (cross-platform compatibility)
599 # Method 1: Direct StatusCode property access
600 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
601 # StatusCode might be an enum - try value__ first, then direct cast
602 $statusCode = $_.Exception.Response.StatusCode.value__ -as [int]
603 if (-not $statusCode) {
604 $statusCode = $_.Exception.Response.StatusCode -as [int]
605 }
606 }
607 # Method 2: Parse status code from exception message (e.g., "404 (Not Found)" or "Response status code does not indicate success: 429")
608 if (-not $statusCode -and $_.Exception.Message -match '\b([45]\d{2})\b') {
609 $statusCode = [int]$Matches[1]
610 }
611 # Method 3: Map common HTTP status text to codes
612 if (-not $statusCode) {
613 $messageUpper = $_.Exception.Message.ToUpper()
614 if ($messageUpper -match 'UNAUTHORIZED') { $statusCode = 401 }
615 elseif ($messageUpper -match 'NOT\s*FOUND') { $statusCode = 404 }
616 elseif ($messageUpper -match 'TOO\s*MANY\s*REQUESTS|RATE\s*LIMIT') { $statusCode = 429 }
617 elseif ($messageUpper -match 'FORBIDDEN') { $statusCode = 403 }
618 elseif ($messageUpper -match 'SERVER\s*ERROR|INTERNAL\s*SERVER') { $statusCode = 500 }
619 elseif ($messageUpper -match 'BAD\s*GATEWAY') { $statusCode = 502 }
620 elseif ($messageUpper -match 'SERVICE\s*UNAVAILABLE') { $statusCode = 503 }
621 elseif ($messageUpper -match 'GATEWAY\s*TIMEOUT') { $statusCode = 504 }
622 }
623
624 # Check if it's a rate limit error (403 or 429) or server error (5xx)
625 $isRetryable = $statusCode -in 403, 429 -or ($statusCode -ge 500 -and $statusCode -lt 600)
626
627 if ($isRetryable -and $attempt -lt $MaxRetries) {
628 Write-Warning "GitHub API request failed (HTTP $statusCode). Retrying in $delay seconds (attempt $attempt/$MaxRetries)..."
629 Start-Sleep -Seconds $delay
630 $delay = $delay * 2 # Exponential backoff
631 }
632 else {
633 if ($attempt -ge $MaxRetries -and $isRetryable) {
634 Write-Error "GitHub API request failed after $MaxRetries attempts: $($_.Exception.Message)" -ErrorAction Continue
635 }
636 else {
637 Write-Error "GitHub API request failed: $($_.Exception.Message)" -ErrorAction Continue
638 }
639 return $null
640 }
641 }
642 }
643
644 return $null
645}
646
647Export-ModuleMember -Function @(
648 'Write-SecurityLog'
649 'New-SecurityIssue'
650 'Write-SecurityReport'
651 'Get-GitHubApiBase'
652 'Get-PSGalleryApiBase'
653 'Test-GitHubToken'
654 'Invoke-GitHubAPIWithRetry'
655)
656