Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
bf6a3ee
feat: add CIS GitHub organization controls
thetechgy May 9, 2026
8aec1d2
chore: tighten CIS GitHub organization controls
thetechgy May 9, 2026
4c190d1
chore: polish CIS GitHub review follow-ups
thetechgy May 9, 2026
30421cc
chore: align CIS GitHub controls with review feedback
thetechgy May 10, 2026
a9c82e2
fix: clarify GitHub CIS manual review results
thetechgy May 10, 2026
432c013
test: tighten CIS GitHub repository creation wording
thetechgy May 10, 2026
e06dca8
docs: align CIS GitHub deletion audit wording
thetechgy May 10, 2026
3d95155
docs: regenerate CIS GitHub test pages
thetechgy May 10, 2026
acfcfe5
fix: align CIS GitHub manual review scoring
thetechgy May 10, 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
9 changes: 6 additions & 3 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 Expand Up @@ -128,7 +128,10 @@
'Test-MtCisConnectionFilterSafeList', 'Test-MtCisCreateTenantDisallowed', 'Test-MtCisCustomerLockBox',
'Test-MtCisDevicesWithoutCompliancePolicyMarked', 'Test-MtCisDkim', 'Test-MtCisEnsureGuestAccessRestricted',
'Test-MtCisEnsureGuestUserDynamicGroup', 'Test-MtCisEnsureUserConsentToAppsDisallowed', 'Test-MtCisExoAdditionalStorageProvider',
'Test-MtCisFormsPhishingProtectionEnabled', 'Test-MtCisGlobalAdminCount', 'Test-MtCisHostedConnectionFilterPolicy',
'Test-MtCisFormsPhishingProtectionEnabled', 'Test-MtCisGitHubIssueDeletionLimited',
'Test-MtCisGitHubRepositoryCreationLimited', 'Test-MtCisGitHubRepositoryDeletionLimited',
'Test-MtCisGitHubStrictBasePermission', 'Test-MtCisGitHubTeamCreationLimited',
'Test-MtCisGlobalAdminCount', 'Test-MtCisHostedConnectionFilterPolicy',
'Test-MtCisInternalMalwareNotification', 'Test-MtCisOutboundSpamFilterPolicy', 'Test-MtCisPasswordExpiry',
'Test-MtCisSafeAntiPhishingPolicy', 'Test-MtCisSafeAttachment', 'Test-MtCisSafeAttachmentsAtpPolicy',
'Test-MtCisSafeLink', 'Test-MtCisSharedMailboxSignIn', 'Test-MtCisTeamsLobbyBypass',
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.
}
17 changes: 17 additions & 0 deletions powershell/internal/Get-MtGitHubCacheKey.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
function Get-MtGitHubCacheKey {
<#
.SYNOPSIS
Internal: Builds the per-session GitHub REST cache key.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true)]
[string] $ApiVersion,

[Parameter(Mandatory = $true)]
[string] $AbsoluteUri
)

return "$ApiVersion|$AbsoluteUri"
}
27 changes: 27 additions & 0 deletions powershell/internal/Get-MtGitHubErrorMessage.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
function Get-MtGitHubErrorMessage {
<#
.SYNOPSIS
Internal: Extracts the most useful GitHub API error message from an ErrorRecord.

.DESCRIPTION
Prefers a JSON ErrorDetails.Message `message` property when present, falls back to
the raw error-details string, then to the exception message or full ErrorRecord text.
#>
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)
}
19 changes: 19 additions & 0 deletions powershell/internal/Get-MtGitHubErrorStatusCode.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
function Get-MtGitHubErrorStatusCode {
<#
.SYNOPSIS
Internal: Extracts an HTTP status code from a GitHub API ErrorRecord.

.DESCRIPTION
Returns the integer status code when the ErrorRecord includes an HTTP response, or
$null for transport failures where no HTTP response was produced.
#>
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
}
17 changes: 17 additions & 0 deletions powershell/internal/Get-MtGitHubOrganization.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
function Get-MtGitHubOrganization {
<#
.SYNOPSIS
Internal: Gets the connected GitHub organization object using the session cache.
#>
[CmdletBinding()]
param()

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

# Connect-MtGitHub stores the raw organization login; encode it here for the path segment.
$encodedOrg = [System.Uri]::EscapeDataString($__MtSession.GitHubConnection.Organization)
Invoke-MtGitHubRequest -RelativeUri "/orgs/$encodedOrg"
}
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
Loading