-
Notifications
You must be signed in to change notification settings - Fork 265
feat: add GitHub REST connection infrastructure (CIS GH PR 1 of 2) #1756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
merill
merged 31 commits into
maester365:main
from
thetechgy:feat/github-cis-foundation
Jun 24, 2026
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
93a9d2a
feat: add GitHub REST API connection infrastructure
thetechgy dc2446d
test: add unit tests for Get-MtGitHubResponseHeaderValue
thetechgy 36f3c61
fix: preserve GitHub session across Invoke-Maester runs
thetechgy 7e4f71c
fix: lazy-load MaesterConfig in Connect-MtGitHub for pre-run invocations
thetechgy 33c2843
test: add unit tests for GitHub connection lifecycle and request wrapper
thetechgy 32e0518
fix: address GitHub review findings — help, error messages, and tests
thetechgy 781eb38
test: replace slow live-probe All test with focused mocked suite
thetechgy b9551c7
fix: address GitHub review findings — token redaction, role probe, te…
thetechgy f442a20
fix: add UTF-8 BOM to PowerShell files containing non-ASCII characters
thetechgy 36ebf95
fix: address GitHub review findings — org access, https check, discon…
thetechgy 247e6d3
feat: add admin permission probe and ApiVersion validation to Connect…
thetechgy ede689e
fix: classify Connect-MtGitHub /user failures by transport vs token
thetechgy 032e08c
feat: render safe GitHub metadata in Test-MtConnection -Details
thetechgy 467d101
fix: refuse cross-origin Link rel=next in Invoke-MtGitHubRequest
thetechgy 4c4e16f
fix: classify GitHub rate-limit responses in Connect-MtGitHub probes
thetechgy 44d68ba
fix: handle empty array responses in Invoke-MtGitHubRequest pagination
thetechgy 3bd671e
fix: fail Connect-MtGitHub when org membership state is not active
thetechgy 9921a2a
fix: detect GitHub secondary rate limits via response body wording
thetechgy 82c524c
fix: harden Connect-MtGitHub URI allowlist and probe error classifica…
thetechgy 9c86d99
fix: trim whitespace from Connect-MtGitHub Organization and ApiVersion
thetechgy 1d5ee68
fix: harden Invoke-MtGitHubRequest rate-limit header parsing
thetechgy 501c641
fix: trim surrounding whitespace from Connect-MtGitHub ApiBaseUri
thetechgy 6cd41c7
fix: align GitHub connection semantics after rebase
merill 388c581
docs: clarify Get-MtSession sanitizes output
merill b527f62
Merge branch 'main' into feat/github-cis-foundation
merill f3d2040
feat: add guided GitHub App connection flow
merill 7ade4d6
fix: address GitHub lifecycle review findings
merill 1c82091
fix: improve GitHub App prompt defaults
merill c37e95b
fix: suppress device flow Write-Host analyzer warning
merill 78853c2
Improve GitHub device flow prompt
merill 775a6d5
Fix GitHub test analyzer warnings
merill File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| function Get-MtGitHubAppDeviceToken { | ||
| <# | ||
| .SYNOPSIS | ||
| Gets a GitHub App user access token using OAuth device flow. | ||
|
|
||
| .DESCRIPTION | ||
| Starts the GitHub App device flow for the Maester CLI GitHub App and polls until | ||
| the user authorizes the app, denies the request, or the device code expires. | ||
| #> | ||
| [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Consistent with other Connect-* interactive flows')] | ||
| [CmdletBinding()] | ||
| [OutputType([pscustomobject])] | ||
| param( | ||
| [Parameter(Mandatory = $true)] | ||
| [string] $ClientId | ||
| ) | ||
|
|
||
| $headers = @{ | ||
| Accept = 'application/json' | ||
| 'User-Agent' = 'Maester-GitHubCis' | ||
| } | ||
|
|
||
| try { | ||
| $deviceResponse = Invoke-WebRequest -Uri 'https://github.com/login/device/code' -Method POST -Headers $headers -Body @{ client_id = $ClientId } -ContentType 'application/x-www-form-urlencoded' -UseBasicParsing -ErrorAction Stop | ||
| $deviceData = $deviceResponse.Content | ConvertFrom-Json -ErrorAction Stop | ||
| } catch { | ||
| Write-Host "`nFailed to start GitHub device authentication: $($_.Exception.Message)" -ForegroundColor Red | ||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowStartFailed' } | ||
| } | ||
|
|
||
| $requiredFields = @('device_code', 'user_code', 'verification_uri') | ||
| foreach ($field in $requiredFields) { | ||
| if ($deviceData.PSObject.Properties.Name -notcontains $field -or [string]::IsNullOrWhiteSpace([string]$deviceData.$field)) { | ||
| Write-Host "`nGitHub device authentication returned an incomplete response." -ForegroundColor Red | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowStartFailed' } | ||
| } | ||
| } | ||
|
|
||
| $interval = 5 | ||
| if ($deviceData.PSObject.Properties.Name -contains 'interval' -and $deviceData.interval -as [int]) { | ||
| $interval = [int]$deviceData.interval | ||
| } | ||
|
|
||
| $expiresIn = 900 | ||
| if ($deviceData.PSObject.Properties.Name -contains 'expires_in' -and $deviceData.expires_in -as [int]) { | ||
| $expiresIn = [int]$deviceData.expires_in | ||
| } | ||
|
|
||
| Write-Host '' | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| Write-Host 'GitHub authentication required for Maester.' -ForegroundColor Yellow | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| Write-Host "! First copy your one-time code: $($deviceData.user_code)" -ForegroundColor Yellow | ||
| $null = Read-Host "Press Enter to open $($deviceData.verification_uri) in your browser" | ||
| $openedBrowser = Open-MtBrowserUrl -Uri $deviceData.verification_uri | ||
| if (-not $openedBrowser) { | ||
| Write-Host "Open $($deviceData.verification_uri) and enter code $($deviceData.user_code)" -ForegroundColor Yellow | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| } | ||
| Write-Host 'Waiting for GitHub authorization...' -ForegroundColor DarkGray | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| $deadline = (Get-Date).ToUniversalTime().AddSeconds($expiresIn) | ||
| while ((Get-Date).ToUniversalTime() -lt $deadline) { | ||
| Start-Sleep -Seconds $interval | ||
|
|
||
| try { | ||
| $tokenResponse = Invoke-WebRequest -Uri 'https://github.com/login/oauth/access_token' -Method POST -Headers $headers -Body @{ | ||
| client_id = $ClientId | ||
| device_code = $deviceData.device_code | ||
| grant_type = 'urn:ietf:params:oauth:grant-type:device_code' | ||
| } -ContentType 'application/x-www-form-urlencoded' -UseBasicParsing -ErrorAction Stop | ||
| $tokenData = $tokenResponse.Content | ConvertFrom-Json -ErrorAction Stop | ||
| } catch { | ||
| Write-Host "`nFailed to complete GitHub device authentication: $($_.Exception.Message)" -ForegroundColor Red | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowFailed' } | ||
| } | ||
|
|
||
| if ($tokenData.PSObject.Properties.Name -contains 'access_token' -and -not [string]::IsNullOrWhiteSpace([string]$tokenData.access_token)) { | ||
| $expiresAt = $null | ||
| if ($tokenData.PSObject.Properties.Name -contains 'expires_in' -and $tokenData.expires_in -as [int]) { | ||
| $expiresAt = (Get-Date).ToUniversalTime().AddSeconds([int]$tokenData.expires_in) | ||
| } | ||
| return [pscustomobject]@{ | ||
| AccessToken = [string]$tokenData.access_token | ||
| ExpiresAt = $expiresAt | ||
| FailureReason = $null | ||
| } | ||
| } | ||
|
|
||
| $errorName = if ($tokenData.PSObject.Properties.Name -contains 'error') { [string]$tokenData.error } else { $null } | ||
| switch ($errorName) { | ||
| 'authorization_pending' { | ||
| continue | ||
| } | ||
| 'slow_down' { | ||
| if ($tokenData.PSObject.Properties.Name -contains 'interval' -and $tokenData.interval -as [int]) { | ||
| $interval = [int]$tokenData.interval | ||
| } else { | ||
| $interval += 5 | ||
| } | ||
| continue | ||
| } | ||
| { $_ -in @('expired_token', 'token_expired') } { | ||
| Write-Host "`nGitHub device authentication expired. Run Connect-MtGitHub again to get a new code." -ForegroundColor Red | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowExpired' } | ||
| } | ||
| 'access_denied' { | ||
| Write-Host "`nGitHub device authentication was denied." -ForegroundColor Red | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowDenied' } | ||
| } | ||
| 'device_flow_disabled' { | ||
| Write-Host "`nGitHub device flow is not enabled for the Maester GitHub App." -ForegroundColor Red | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowDisabled' } | ||
| } | ||
| default { | ||
| $details = if ($errorName) { " GitHub returned '$errorName'." } else { '' } | ||
| Write-Host "`nGitHub device authentication failed.$details" -ForegroundColor Red | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowFailed' } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Write-Host "`nGitHub device authentication expired. Run Connect-MtGitHub again to get a new code." -ForegroundColor Red | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowExpired' } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| function Get-MtGitHubErrorMessage { | ||
| param([Parameter(Mandatory)] $ErrorRecord) | ||
| if (-not [string]::IsNullOrEmpty($ErrorRecord.ErrorDetails.Message)) { | ||
| try { | ||
| $parsed = $ErrorRecord.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop | ||
| if ($parsed.PSObject.Properties.Name -contains 'message' -and | ||
| -not [string]::IsNullOrEmpty($parsed.message)) { | ||
| return $parsed.message | ||
| } | ||
| } catch { | ||
| Write-Debug "Get-MtGitHubErrorMessage: ErrorDetails.Message is not JSON, returning raw string." | ||
| } | ||
| return $ErrorRecord.ErrorDetails.Message | ||
| } | ||
| if (-not [string]::IsNullOrEmpty($ErrorRecord.Exception.Message)) { | ||
| return $ErrorRecord.Exception.Message | ||
| } | ||
| return ($ErrorRecord | Out-String) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| function Get-MtGitHubErrorStatusCode { | ||
| param([Parameter(Mandatory)] $ErrorRecord) | ||
| try { | ||
| if ($ErrorRecord.Exception.Response -and $ErrorRecord.Exception.Response.StatusCode) { | ||
| return [int]$ErrorRecord.Exception.Response.StatusCode | ||
| } | ||
| } catch { | ||
| Write-Debug "Get-MtGitHubErrorStatusCode: $($_.Exception.Message)" | ||
| } | ||
| return $null | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| function Get-MtGitHubRateLimitMessage { | ||
| <# | ||
| .SYNOPSIS | ||
| Internal: Returns a GitHub rate-limit message for an ErrorRecord, or $null when the | ||
| error is not a rate-limit response. | ||
|
|
||
| .DESCRIPTION | ||
| Mirrors the rate-limit detection in Invoke-MtGitHubRequest so that bootstrap callers | ||
| (Connect-MtGitHub) can distinguish HTTP 403/429 caused by rate limiting from | ||
| permission, token, or org-access failures. | ||
|
|
||
| Returns: | ||
| - "GitHub API rate limit encountered (HTTP <code>). Resets at: <time>" when the | ||
| response carries x-ratelimit-remaining = 0 (primary rate limit). | ||
| - "GitHub secondary rate limit encountered (HTTP <code>). Retry after: <n>s" when | ||
| the response carries retry-after (secondary rate limit / abuse detection). | ||
| - "GitHub secondary rate limit encountered (HTTP <code>). Retry after at least 60s." | ||
| when the response body indicates secondary-limit / abuse-detection wording but | ||
| no retry-after header is present. | ||
| - $null for any other error, including 403/429 without rate-limit headers. | ||
| #> | ||
| param( | ||
| [Parameter(Mandatory)] $ErrorRecord | ||
| ) | ||
|
|
||
| $code = Get-MtGitHubErrorStatusCode -ErrorRecord $ErrorRecord | ||
| if ($code -notin 403, 429) { return $null } | ||
|
|
||
| $response = $null | ||
| try { | ||
| if ($null -ne $ErrorRecord.Exception) { $response = $ErrorRecord.Exception.Response } | ||
| } catch { | ||
| Write-Debug "Get-MtGitHubRateLimitMessage: $($_.Exception.Message)" | ||
| } | ||
| if ($null -eq $response) { return $null } | ||
|
|
||
| $headers = $null | ||
| try { $headers = $response.Headers } catch { | ||
| Write-Debug "Get-MtGitHubRateLimitMessage headers: $($_.Exception.Message)" | ||
| } | ||
| if ($null -eq $headers) { return $null } | ||
|
|
||
| $remaining = Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-remaining' | ||
| $remainingValue = 0 | ||
| $remainingParsed = $null -ne $remaining -and [int]::TryParse([string]$remaining, [ref]$remainingValue) | ||
| if ($remainingParsed -and $remainingValue -eq 0) { | ||
| $reset = Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-reset' | ||
| $resetSeconds = 0L | ||
| $resetTime = 'unknown' | ||
| if ($null -ne $reset -and [long]::TryParse([string]$reset, [ref]$resetSeconds)) { | ||
| try { $resetTime = [DateTimeOffset]::FromUnixTimeSeconds($resetSeconds).LocalDateTime } catch { | ||
| Write-Debug "Get-MtGitHubRateLimitMessage reset conversion: $($_.Exception.Message)" | ||
| } | ||
| } | ||
| return "GitHub API rate limit encountered (HTTP $code). Resets at: $resetTime" | ||
| } | ||
|
|
||
| $retryAfter = Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'retry-after' | ||
| if ($null -ne $retryAfter) { | ||
| return "GitHub secondary rate limit encountered (HTTP $code). Retry after: ${retryAfter}s" | ||
| } | ||
|
|
||
| # Some secondary-limit responses omit retry-after entirely (e.g. older abuse-detection | ||
| # responses, or responses where the proxy strips the header). Fall back to body wording - | ||
| # GitHub's documented messages include phrases like "secondary rate limit" and | ||
| # "abuse detection mechanism". When matched, recommend the 60-second minimum backoff | ||
| # documented at https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api. | ||
| $bodyMessage = Get-MtGitHubErrorMessage -ErrorRecord $ErrorRecord | ||
| if (-not [string]::IsNullOrEmpty($bodyMessage) -and | ||
| $bodyMessage -match '(?i)secondary\s+rate\s+limit|abuse\s+detection') { | ||
| return "GitHub secondary rate limit encountered (HTTP $code). Retry after at least 60s." | ||
| } | ||
|
|
||
| return $null | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| function Get-MtGitHubResponseHeaderValue { | ||
| param( | ||
| [Parameter()] $Headers, | ||
| [Parameter(Mandatory)] [string] $Name | ||
| ) | ||
| if ($null -eq $Headers) { return $null } | ||
| # IDictionary covers PS 5.1 WebHeaderCollection and PS 7 Dictionary. | ||
| # Iterate keys with -ieq for case-insensitive match (plain hashtables are case-sensitive). | ||
| if ($Headers -is [System.Collections.IDictionary]) { | ||
| foreach ($key in $Headers.Keys) { | ||
| if ($key -ieq $Name) { | ||
| $value = $Headers[$key] | ||
| if ($value -is [array]) { return $value[0] } | ||
| return $value | ||
| } | ||
| } | ||
| return $null | ||
| } | ||
| # HttpResponseHeaders in PS 7 exposes GetValues / TryGetValues | ||
| if ($Headers.PSObject.Methods.Name -contains 'GetValues') { | ||
| try { return ($Headers.GetValues($Name) | Select-Object -First 1) } catch { | ||
| Write-Debug "Get-MtGitHubResponseHeaderValue GetValues: $($_.Exception.Message)" | ||
| } | ||
| } | ||
| if ($Headers.PSObject.Methods.Name -contains 'TryGetValues') { | ||
| try { | ||
| $values = $null | ||
| if ($Headers.TryGetValues($Name, [ref]$values)) { | ||
| return ($values | Select-Object -First 1) | ||
| } | ||
| } catch { | ||
| Write-Debug "Get-MtGitHubResponseHeaderValue TryGetValues: $($_.Exception.Message)" | ||
| } | ||
| } | ||
| return $null | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.