Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7341895
feat: add GitHub REST API connection infrastructure
thetechgy May 7, 2026
358e69b
test: add unit tests for Get-MtGitHubResponseHeaderValue
thetechgy May 7, 2026
0908399
fix: preserve GitHub session across Invoke-Maester runs
thetechgy May 7, 2026
3f7a77b
fix: lazy-load MaesterConfig in Connect-MtGitHub for pre-run invocations
thetechgy May 7, 2026
8db4bed
test: add unit tests for GitHub connection lifecycle and request wrapper
thetechgy May 7, 2026
9104f63
fix: address GitHub review findings β€” help, error messages, and tests
thetechgy May 7, 2026
b35b714
fix: make GitHub opt-in for Test-MtConnection -Service All
thetechgy May 7, 2026
eea8b08
test: replace slow live-probe All test with focused mocked suite
thetechgy May 7, 2026
03125ac
fix: address GitHub review findings β€” token redaction, role probe, te…
thetechgy May 8, 2026
5eecb13
fix: add UTF-8 BOM to PowerShell files containing non-ASCII characters
thetechgy May 8, 2026
51fc000
fix: address GitHub review findings β€” org access, https check, discon…
thetechgy May 8, 2026
d799363
feat: add admin permission probe and ApiVersion validation to Connect…
thetechgy May 8, 2026
d8764e0
fix: classify Connect-MtGitHub /user failures by transport vs token
thetechgy May 8, 2026
e0b6514
feat: render safe GitHub metadata in Test-MtConnection -Details
thetechgy May 9, 2026
b97ae8c
fix: always clear GitHub state in Disconnect-Maester via try/finally
thetechgy May 9, 2026
4cc58b7
fix: refuse cross-origin Link rel=next in Invoke-MtGitHubRequest
thetechgy May 9, 2026
17ac2ed
fix: classify GitHub rate-limit responses in Connect-MtGitHub probes
thetechgy May 9, 2026
2b442b8
fix: handle empty array responses in Invoke-MtGitHubRequest pagination
thetechgy May 9, 2026
24938cf
fix: fail Connect-MtGitHub when org membership state is not active
thetechgy May 9, 2026
e597251
fix: detect GitHub secondary rate limits via response body wording
thetechgy May 9, 2026
00387f3
fix: harden Connect-MtGitHub URI allowlist and probe error classifica…
thetechgy May 9, 2026
2c9b161
fix: trim whitespace from Connect-MtGitHub Organization and ApiVersion
thetechgy May 9, 2026
6169ec0
fix: harden Invoke-MtGitHubRequest rate-limit header parsing
thetechgy May 9, 2026
1f42bfd
fix: trim surrounding whitespace from Connect-MtGitHub ApiBaseUri
thetechgy May 9, 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 @@ -103,6 +103,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-MtRole',
Expand Down
1 change: 1 addition & 0 deletions powershell/Maester.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ $__MtSession = @{
DataverseApiBase = $null # Resolved Dataverse OData API base URL (e.g. https://org123.api.crm.dynamics.com/api/data/v9.2)
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)
GitHubCache = @{} # Per-session REST response cache; cleared each Invoke-Maester run
}
New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force

Expand Down
3 changes: 3 additions & 0 deletions powershell/internal/Clear-ModuleVariable.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@
Clear-MtExoCache
$__MtSession.AIAgentInfo = $null
$__MtSession.AzureDevOpsConnection = $null
# Do not clear GitHubConnection or GitHubAuthHeader β€” the user calls Connect-MtGitHub before
# Invoke-Maester; the session must persist across runs. Only the per-run cache is reset.
$__MtSession.GitHubCache = @{}
# $__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.
}
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 a valid PAT and organization name. 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}
"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}
"NotLicensedEntraIDP1" { "This test is for tenants that are licensed for Entra ID P1. See [Entra ID licensing](https://learn.microsoft.com/entra/fundamentals/licensing)"; break}
Expand Down
152 changes: 152 additions & 0 deletions powershell/internal/Invoke-MtGitHubRequest.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
ο»Ώfunction Invoke-MtGitHubRequest {
<#
.SYNOPSIS
Internal: Authenticated read-only GET request to GitHub REST API with caching and pagination.

.DESCRIPTION
Uses Invoke-WebRequest for PowerShell 5.1 compatibility (Invoke-RestMethod
-ResponseHeadersVariable is PowerShell 7+ only). Provides per-session caching,
explicit opt-in pagination, and rate-limit detection.

Cache key: ApiVersion|absoluteUri (cleared on reconnect and by Clear-ModuleVariable).
Rate-limit detection: checks x-ratelimit-remaining in both success and error responses.

.PARAMETER RelativeUri
Path relative to ApiBaseUri. URL-encode path segments with [Uri]::EscapeDataString.

.PARAMETER Paginate
Follows Link header rel="next" and appends per_page=100. Use for list endpoints only.

.PARAMETER DisableCache
Bypasses session cache; makes a live API call.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string] $RelativeUri,
[switch] $Paginate,
[switch] $DisableCache
)

if ($null -eq $__MtSession.GitHubConnection -or
$__MtSession.GitHubConnection.Connected -ne $true) {
throw "Not connected to GitHub. Call Connect-MtGitHub first."
}

$baseUri = $__MtSession.GitHubConnection.ApiBaseUri
$version = $__MtSession.GitHubConnection.ApiVersion
$headers = $__MtSession.GitHubAuthHeader

$absUri = "$baseUri/$($RelativeUri.TrimStart('/'))"
if ($Paginate -and $absUri -notmatch '[?&]per_page=') {
$sep = if ($absUri -match '\?') { '&' } else { '?' }
$absUri = "${absUri}${sep}per_page=100"
}

$cacheKey = "$version|$absUri"
if (-not $DisableCache -and $__MtSession.GitHubCache.ContainsKey($cacheKey)) {
Write-Verbose "GitHub cache hit: $absUri"
return $__MtSession.GitHubCache[$cacheKey]
}

function Invoke-Page ([string]$Uri) {
try {
$wr = Invoke-WebRequest -Uri $Uri -Headers $headers -Method GET -UseBasicParsing -ErrorAction Stop

$body = if (-not [string]::IsNullOrWhiteSpace($wr.Content)) {
# ConvertFrom-Json '[]' yields $null because it enumerates the (empty)
# array, so pagination can't distinguish "no items" from "one null item"
# without short-circuiting the empty-array case here.
if ($wr.Content.Trim() -eq '[]') {
,@()
} else {
$wr.Content | ConvertFrom-Json
}
} else {
$null
}

# Rate-limit warning on successful response β€” do NOT throw; the response body is valid.
# TryParse rather than [int] cast: a malformed header (e.g. an upstream proxy
# rewriting the value) must not raise a parse exception that masks a successful response.
$remaining = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'x-ratelimit-remaining'
$remainingValue = 0
if ($null -ne $remaining -and [int]::TryParse([string]$remaining, [ref]$remainingValue) -and $remainingValue -eq 0) {
$reset = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'x-ratelimit-reset'
$resetValue = 0L
$resetTime = 'unknown'
if ($reset -and [long]::TryParse([string]$reset, [ref]$resetValue)) {
# FromUnixTimeSeconds throws ArgumentOutOfRangeException for values
# outside [-62135596800, 253402300799]. A bogus reset epoch must not
# mask the successful response β€” fall back to 'unknown' instead.
try {
$resetTime = [DateTimeOffset]::FromUnixTimeSeconds($resetValue).LocalDateTime
} catch {
$resetTime = 'unknown'
}
}
Write-Verbose "GitHub API rate limit remaining is 0 after this successful response. Resets at: $resetTime"
}

$linkHeader = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'Link'
return [PSCustomObject]@{ Body = $body; Link = $linkHeader }
} catch {
$rateLimitMessage = Get-MtGitHubRateLimitMessage -ErrorRecord $_
if ($rateLimitMessage) { throw $rateLimitMessage }
throw
}
}

function Get-NextLink ([string]$Link) {
if ([string]::IsNullOrEmpty($Link)) { return $null }
$m = [regex]::Match($Link, '<([^>]+)>;\s*rel="next"')
if ($m.Success) { return $m.Groups[1].Value }
return $null
}

# Refuse to follow a Link rel="next" that points outside the configured ApiBaseUri.
# A malicious or buggy upstream that injects a foreign URL would otherwise receive
# the Authorization header on a cross-origin request. Compare scheme + host + port
# + base path prefix so GHE bases like https://host/api/v3 are honored.
function Test-NextLinkSameOrigin ([string]$NextUri, [string]$BaseUri) {
$baseParsed = $null
$nextParsed = $null
if (-not [uri]::TryCreate($BaseUri, [UriKind]::Absolute, [ref]$baseParsed)) { return $false }
if (-not [uri]::TryCreate($NextUri, [UriKind]::Absolute, [ref]$nextParsed)) { return $false }
if ($baseParsed.Scheme -ne $nextParsed.Scheme) { return $false }
if (-not [string]::Equals($baseParsed.Host, $nextParsed.Host, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($baseParsed.Port -ne $nextParsed.Port) { return $false }
$basePath = $baseParsed.AbsolutePath.TrimEnd('/')
$nextPath = $nextParsed.AbsolutePath
if ([string]::IsNullOrEmpty($basePath)) { return $true }
return $nextPath -eq $basePath -or $nextPath.StartsWith("$basePath/", [System.StringComparison]::Ordinal)
}

$first = Invoke-Page $absUri

if (-not $Paginate) {
$result = $first.Body
} else {
$all = [System.Collections.Generic.List[object]]::new()
# Filter $null per page so an empty-content or `[]` page does not contribute
# spurious null items to the merged result.
foreach ($item in @($first.Body)) {
if ($null -ne $item) { $all.Add($item) }
}
$next = Get-NextLink $first.Link
while ($null -ne $next) {
if (-not (Test-NextLinkSameOrigin -NextUri $next -BaseUri $baseUri)) {
throw "GitHub pagination refused: next link '$next' is outside the configured ApiBaseUri '$baseUri'."
}
$page = Invoke-Page $next
foreach ($item in @($page.Body)) {
if ($null -ne $item) { $all.Add($item) }
}
$next = Get-NextLink $page.Link
}
$result = $all.ToArray()
}

if (-not $DisableCache) { $__MtSession.GitHubCache[$cacheKey] = $result }
return $result
}
2 changes: 1 addition & 1 deletion powershell/public/Add-MtTestResultDetail.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
[ValidateSet('NotConnectedAzure', 'NotConnectedExchange', 'NotConnectedGraph', 'NotDotGovDomain', 'NotLicensedEntraIDP1', 'NotConnectedSecurityCompliance', 'NotConnectedTeams',
'NotLicensedEntraIDP2', 'NotLicensedEntraIDGovernance', 'NotLicensedEntraWorkloadID', 'NotLicensedExoDlp', "LicensedEntraIDPremium", 'NotSupported', 'Custom',
'NotLicensedMdo', 'NotLicensedMdoP2', 'NotLicensedMdoP1', 'NotLicensedAdvAudit', 'NotLicensedEop', 'Error', 'NotSupportedAppPermission', 'LimitedPermissions', 'NotLicensedDefenderXDR',
'NotLicensedCustomerLockbox','NotAuthorized', 'NotLicensedIntune', 'NotConnectedAzureDevOps'
'NotLicensedCustomerLockbox','NotAuthorized', 'NotLicensedIntune', 'NotConnectedAzureDevOps', 'NotConnectedGitHub'
)]
[string] $SkippedBecause,

Expand Down
Loading