Skip to content
Merged
Show file tree
Hide file tree
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 May 7, 2026
dc2446d
test: add unit tests for Get-MtGitHubResponseHeaderValue
thetechgy May 7, 2026
36f3c61
fix: preserve GitHub session across Invoke-Maester runs
thetechgy May 7, 2026
7e4f71c
fix: lazy-load MaesterConfig in Connect-MtGitHub for pre-run invocations
thetechgy May 7, 2026
33c2843
test: add unit tests for GitHub connection lifecycle and request wrapper
thetechgy May 7, 2026
32e0518
fix: address GitHub review findings — help, error messages, and tests
thetechgy May 7, 2026
781eb38
test: replace slow live-probe All test with focused mocked suite
thetechgy May 7, 2026
b9551c7
fix: address GitHub review findings — token redaction, role probe, te…
thetechgy May 8, 2026
f442a20
fix: add UTF-8 BOM to PowerShell files containing non-ASCII characters
thetechgy May 8, 2026
36ebf95
fix: address GitHub review findings — org access, https check, discon…
thetechgy May 8, 2026
247e6d3
feat: add admin permission probe and ApiVersion validation to Connect…
thetechgy May 8, 2026
ede689e
fix: classify Connect-MtGitHub /user failures by transport vs token
thetechgy May 8, 2026
032e08c
feat: render safe GitHub metadata in Test-MtConnection -Details
thetechgy May 9, 2026
467d101
fix: refuse cross-origin Link rel=next in Invoke-MtGitHubRequest
thetechgy May 9, 2026
4c4e16f
fix: classify GitHub rate-limit responses in Connect-MtGitHub probes
thetechgy May 9, 2026
44d68ba
fix: handle empty array responses in Invoke-MtGitHubRequest pagination
thetechgy May 9, 2026
3bd671e
fix: fail Connect-MtGitHub when org membership state is not active
thetechgy May 9, 2026
9921a2a
fix: detect GitHub secondary rate limits via response body wording
thetechgy May 9, 2026
82c524c
fix: harden Connect-MtGitHub URI allowlist and probe error classifica…
thetechgy May 9, 2026
9c86d99
fix: trim whitespace from Connect-MtGitHub Organization and ApiVersion
thetechgy May 9, 2026
1d5ee68
fix: harden Invoke-MtGitHubRequest rate-limit header parsing
thetechgy May 9, 2026
501c641
fix: trim surrounding whitespace from Connect-MtGitHub ApiBaseUri
thetechgy May 9, 2026
6cd41c7
fix: align GitHub connection semantics after rebase
merill Jun 22, 2026
388c581
docs: clarify Get-MtSession sanitizes output
merill Jun 22, 2026
b527f62
Merge branch 'main' into feat/github-cis-foundation
merill Jun 24, 2026
f3d2040
feat: add guided GitHub App connection flow
merill Jun 24, 2026
7ade4d6
fix: address GitHub lifecycle review findings
merill Jun 24, 2026
1c82091
fix: improve GitHub App prompt defaults
merill Jun 24, 2026
c37e95b
fix: suppress device flow Write-Host analyzer warning
merill Jun 24, 2026
78853c2
Improve GitHub device flow prompt
merill Jun 24, 2026
775a6d5
Fix GitHub test analyzer warnings
merill Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions powershell/Maester.Format.ps1xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@
}
</ScriptBlock>
</ListItem>
<ListItem>
<ItemSelectionCondition>
<ScriptBlock>
$null -ne $_.GitHub
</ScriptBlock>
</ItemSelectionCondition>
<Label>GitHub</Label>
<ScriptBlock>
if ($_.GitHub) {
$connectedText = if ($_.GitHub.Connected) { 'Connected' } else { 'Not connected' }
"$connectedText`n" +
"Organization: $($_.GitHub.Organization)`n" +
"Token Login: $($_.GitHub.TokenLogin)`n" +
"API Base URI: $($_.GitHub.ApiBaseUri)`n" +
"Role: $($_.GitHub.Role)`n" +
"Role State: $($_.GitHub.RoleState)`n" +
"Administration Permission Verified: $($_.GitHub.AdministrationPermissionVerified)`n"
} else {
''
}
</ScriptBlock>
</ListItem>
</ListItems>
</ListEntry>
</ListEntries>
Expand Down
4 changes: 2 additions & 2 deletions powershell/Maester.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
FunctionsToExport = @(
'Add-MtMaesterAppFederatedCredential', 'Add-MtTestResultDetail', 'Clear-MtDnsCache', 'Clear-MtExoCache',
'Clear-MtGraphCache', 'Compare-MtJsonObject', 'Compare-MtTestResult', 'Connect-Maester', 'Convert-MtResultsToFlatObject',
'Clear-MtGraphCache', 'Compare-MtJsonObject', 'Compare-MtTestResult', 'Connect-Maester', 'Connect-MtGitHub', 'Convert-MtResultsToFlatObject',
'ConvertFrom-MailAuthenticationRecordDkim', 'ConvertFrom-MailAuthenticationRecordDmarc',
'ConvertFrom-MailAuthenticationRecordMx', 'ConvertFrom-MailAuthenticationRecordSpf', 'Disconnect-Maester',
'ConvertFrom-MailAuthenticationRecordMx', 'ConvertFrom-MailAuthenticationRecordSpf', 'Disconnect-Maester', 'Disconnect-MtGitHub',
'Get-MailAuthenticationRecord', 'Get-MtAdminPortalUrl', 'Get-MtAuthenticationMethodPolicyConfig',
'Get-MtAzureManagementGroup', 'Get-MtConditionalAccessPolicy', 'Get-MtExo', 'Get-MtExoThreatPolicyMalware',
'Get-MtGraphScope', 'Get-MtGroupMember', 'Get-MtHtmlReport', 'Get-MtLicenseInformation', 'Get-MtMaesterApp', 'Get-MtRegistrableDomain', 'Get-MtRole',
Expand Down
3 changes: 2 additions & 1 deletion powershell/Maester.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ $__MtSession = @{
DataverseResourceUrl = $null # Dataverse resource URL for token acquisition (e.g. https://org123.crm.dynamics.com)
DataverseEnvironmentId = $null # Environment identifier for display (e.g. org123.crm.dynamics.com)
SpoCache = @{} # Cache for SharePoint Online tenant settings retrieved via PnP
GitHubCache = @{} # Per-session REST response cache; cleared each Invoke-Maester run
}
New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force

Expand Down Expand Up @@ -60,4 +61,4 @@ try {
} catch {
Write-Warning "Failed to load module manifest: $($_.Exception.Message)"
$ModuleInfo = $null
}
}
3 changes: 3 additions & 0 deletions powershell/internal/Clear-ModuleVariable.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
Clear-MtExoCache
$__MtSession.AIAgentInfo = $null
$__MtSession.AzureDevOpsConnection = $null
# Invoke-Maester resets the per-run cache but preserves the existing GitHub session.
# Disconnect-MtGitHub, including through Disconnect-Maester, owns clearing GitHubConnection and GitHubAuthHeader.
$__MtSession.GitHubCache = @{}
$__MtSession.SpoCache = @{}
# $__MtSession.Connections = @() # Do not clear connections as they are used to track the connection state. This module variable should only be set by Connect-Maester and Disconnect-Maester.
}
122 changes: 122 additions & 0 deletions powershell/internal/Get-MtGitHubAppDeviceToken.ps1
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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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
Comment thread
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 ''
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Write-Host 'GitHub authentication required for Maester.' -ForegroundColor Yellow
Comment thread
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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
Write-Host 'Waiting for GitHub authorization...' -ForegroundColor DarkGray
Comment thread
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
Comment thread
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
Comment thread
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
Comment thread
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
Comment thread
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
Comment thread
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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return [pscustomobject]@{ AccessToken = $null; FailureReason = 'GitHubDeviceFlowExpired' }
}
19 changes: 19 additions & 0 deletions powershell/internal/Get-MtGitHubErrorMessage.ps1
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)
}
11 changes: 11 additions & 0 deletions powershell/internal/Get-MtGitHubErrorStatusCode.ps1
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
}
75 changes: 75 additions & 0 deletions powershell/internal/Get-MtGitHubRateLimitMessage.ps1
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
}
36 changes: 36 additions & 0 deletions powershell/internal/Get-MtGitHubResponseHeaderValue.ps1
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
}
1 change: 1 addition & 0 deletions powershell/internal/Get-MtSkippedReason.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"NotConnectedSecurityCompliance" { "Not connected to Security & Compliance. See [Connecting to Security & Compliance](https://maester.dev/docs/connect-maester/#connect-to-azure-exchange-online-and-teams)"; break }
"NotConnectedTeams" { "Not connected to Teams. See [Connecting to Teams](https://maester.dev/docs/connect-maester/#connect-to-azure-exchange-online-and-teams)"; break }
"NotConnectedAzureDevOps" { "Not connected to Azure DevOps. See [Connecting to Azure DevOps](https://maester.dev/docs/connect-maester/#connect-to-azure-devops-optional)"; break }
"NotConnectedGitHub" { "Not connected to GitHub. Call Connect-MtGitHub with an organization name to sign in with the Maester GitHub App, or provide a valid automation token. See [Connect-MtGitHub](https://maester.dev/docs/commands/Connect-MtGitHub)"; break }
"NotConnectedGraph" { "Not connected to Graph. See [Connect-Maester](https://maester.dev/docs/commands/Connect-Maester#examples)"; break }
"NotConnectedSharePoint" { "Not connected to SharePoint Online. See [Connecting to SharePoint Online](https://maester.dev/docs/connect-maester/#connect-to-sharepoint-online-optional)"; break }
"NotDotGovDomain" { "This test is only for federal, executive branch, departments and agencies. To override use [Test-MtCisaDmarcAggregateCisa -Force](https://maester.dev/docs/commands/Test-MtCisaDmarcAggregateCisa)"; break }
Expand Down
Loading
Loading