From 734189590e69b1fd1478924bb95d45cf9f38e364 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 02:16:02 +0000 Subject: [PATCH 01/24] feat: add GitHub REST API connection infrastructure Introduces the foundation layer for Maester CIS GitHub Enterprise Cloud benchmark tests. CIS controls consuming this infrastructure arrive in a follow-up PR; this PR intentionally ships connection-only to allow maintainer review of the integration pattern before controls are added. New public function: - Connect-MtGitHub: validates a PAT via GET /user and GET /orgs/{org}, stores session state in $__MtSession, supports MAESTER_GITHUB_TOKEN and GH_TOKEN env vars, and is configurable for GHE.com EMU deployments via -ApiBaseUri. New internal functions: - Invoke-MtGitHubRequest: PS 5.1-compatible (Invoke-WebRequest) cached, paginating GET wrapper with rate-limit detection. - Get-MtGitHubResponseHeaderValue: case-insensitive header extraction compatible with both PS 5.1 WebHeaderCollection and PS 7 HttpResponseHeaders. - Get-MtGitHubErrorStatusCode: safely extracts HTTP status codes from exception responses cross-platform. - Get-MtGitHubErrorMessage: extracts GitHub API error body for skip and error detail output. Changes to existing files: - Maester.psm1: adds GitHubCache = @{} to $__MtSession initializer. - Clear-ModuleVariable.ps1: resets GitHubConnection, GitHubAuthHeader, and GitHubCache on each Invoke-Maester run. - Test-MtConnection.ps1: adds 'GitHub' service region using cached $__MtSession.GitHubConnection state (no auto-detect; requires explicit Connect-MtGitHub call). - Add-MtTestResultDetail.ps1: adds 'NotConnectedGitHub' to ValidateSet. - Get-MtSkippedReason.ps1: adds 'NotConnectedGitHub' skip message. - Maester.psd1: exports Connect-MtGitHub. - tests/maester-config.json: adds GitHubOrganization, GitHubApiBaseUri, and GitHubApiVersion to GlobalSettings. Co-Authored-By: Claude Sonnet 4.6 --- powershell/Maester.psd1 | 2 +- powershell/Maester.psm1 | 1 + powershell/internal/Clear-ModuleVariable.ps1 | 3 + .../internal/Get-MtGitHubErrorMessage.ps1 | 10 + .../internal/Get-MtGitHubErrorStatusCode.ps1 | 11 ++ .../Get-MtGitHubResponseHeaderValue.ps1 | 36 ++++ powershell/internal/Get-MtSkippedReason.ps1 | 1 + .../internal/Invoke-MtGitHubRequest.ps1 | 125 +++++++++++++ powershell/public/Add-MtTestResultDetail.ps1 | 2 +- powershell/public/core/Connect-MtGitHub.ps1 | 172 ++++++++++++++++++ powershell/public/core/Test-MtConnection.ps1 | 20 +- tests/maester-config.json | 5 +- 12 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 powershell/internal/Get-MtGitHubErrorMessage.ps1 create mode 100644 powershell/internal/Get-MtGitHubErrorStatusCode.ps1 create mode 100644 powershell/internal/Get-MtGitHubResponseHeaderValue.ps1 create mode 100644 powershell/internal/Invoke-MtGitHubRequest.ps1 create mode 100644 powershell/public/core/Connect-MtGitHub.ps1 diff --git a/powershell/Maester.psd1 b/powershell/Maester.psd1 index a8142ff5c..2376e8829 100644 --- a/powershell/Maester.psd1 +++ b/powershell/Maester.psd1 @@ -57,7 +57,7 @@ # 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', 'Get-MailAuthenticationRecord', 'Get-MtAdminPortalUrl', 'Get-MtAuthenticationMethodPolicyConfig', diff --git a/powershell/Maester.psm1 b/powershell/Maester.psm1 index 1cb989e9f..3dd22854f 100644 --- a/powershell/Maester.psm1 +++ b/powershell/Maester.psm1 @@ -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 diff --git a/powershell/internal/Clear-ModuleVariable.ps1 b/powershell/internal/Clear-ModuleVariable.ps1 index 17879e660..253b22399 100644 --- a/powershell/internal/Clear-ModuleVariable.ps1 +++ b/powershell/internal/Clear-ModuleVariable.ps1 @@ -21,5 +21,8 @@ Clear-MtExoCache $__MtSession.AIAgentInfo = $null $__MtSession.AzureDevOpsConnection = $null + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__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. } diff --git a/powershell/internal/Get-MtGitHubErrorMessage.ps1 b/powershell/internal/Get-MtGitHubErrorMessage.ps1 new file mode 100644 index 000000000..508196580 --- /dev/null +++ b/powershell/internal/Get-MtGitHubErrorMessage.ps1 @@ -0,0 +1,10 @@ +function Get-MtGitHubErrorMessage { + param([Parameter(Mandatory)] $ErrorRecord) + if (-not [string]::IsNullOrEmpty($ErrorRecord.ErrorDetails.Message)) { + return $ErrorRecord.ErrorDetails.Message + } + if (-not [string]::IsNullOrEmpty($ErrorRecord.Exception.Message)) { + return $ErrorRecord.Exception.Message + } + return ($ErrorRecord | Out-String) +} diff --git a/powershell/internal/Get-MtGitHubErrorStatusCode.ps1 b/powershell/internal/Get-MtGitHubErrorStatusCode.ps1 new file mode 100644 index 000000000..046d6c590 --- /dev/null +++ b/powershell/internal/Get-MtGitHubErrorStatusCode.ps1 @@ -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 +} diff --git a/powershell/internal/Get-MtGitHubResponseHeaderValue.ps1 b/powershell/internal/Get-MtGitHubResponseHeaderValue.ps1 new file mode 100644 index 000000000..37af59949 --- /dev/null +++ b/powershell/internal/Get-MtGitHubResponseHeaderValue.ps1 @@ -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 +} diff --git a/powershell/internal/Get-MtSkippedReason.ps1 b/powershell/internal/Get-MtSkippedReason.ps1 index 3e7c806ac..fb298dfbf 100644 --- a/powershell/internal/Get-MtSkippedReason.ps1 +++ b/powershell/internal/Get-MtSkippedReason.ps1 @@ -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} diff --git a/powershell/internal/Invoke-MtGitHubRequest.ps1 b/powershell/internal/Invoke-MtGitHubRequest.ps1 new file mode 100644 index 000000000..10b9a0c62 --- /dev/null +++ b/powershell/internal/Invoke-MtGitHubRequest.ps1 @@ -0,0 +1,125 @@ +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)) { + $wr.Content | ConvertFrom-Json + } else { + $null + } + + # Rate-limit warning on successful response — do NOT throw; the response body is valid. + $remaining = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'x-ratelimit-remaining' + if ($null -ne $remaining -and [int]$remaining -eq 0) { + $reset = Get-MtGitHubResponseHeaderValue -Headers $wr.Headers -Name 'x-ratelimit-reset' + $resetTime = if ($reset) { [DateTimeOffset]::FromUnixTimeSeconds([long]$reset).LocalDateTime } else { '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 { + $code = Get-MtGitHubErrorStatusCode -ErrorRecord $_ + + if ($code -in 403, 429) { + try { + $errResp = $_.Exception.Response + if ($errResp) { + $errHeaders = $errResp.Headers + $remaining = Get-MtGitHubResponseHeaderValue -Headers $errHeaders -Name 'x-ratelimit-remaining' + $retryAfter = Get-MtGitHubResponseHeaderValue -Headers $errHeaders -Name 'retry-after' + if ($null -ne $remaining -and [int]$remaining -eq 0) { + $reset = Get-MtGitHubResponseHeaderValue -Headers $errHeaders -Name 'x-ratelimit-reset' + $resetTime = if ($reset) { [DateTimeOffset]::FromUnixTimeSeconds([long]$reset).LocalDateTime } else { 'unknown' } + throw "GitHub API rate limit encountered (HTTP $code). Resets at: $resetTime" + } + if ($null -ne $retryAfter) { + throw "GitHub secondary rate limit encountered (HTTP $code). Retry after: ${retryAfter}s" + } + } + } catch { + if ($_.Exception.Message -match 'rate limit') { throw } + } + } + + 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 + } + + $first = Invoke-Page $absUri + + if (-not $Paginate) { + $result = $first.Body + } else { + $all = [System.Collections.Generic.List[object]]::new() + $all.AddRange(@($first.Body)) + $next = Get-NextLink $first.Link + while ($null -ne $next) { + $page = Invoke-Page $next + $all.AddRange(@($page.Body)) + $next = Get-NextLink $page.Link + } + $result = $all.ToArray() + } + + if (-not $DisableCache) { $__MtSession.GitHubCache[$cacheKey] = $result } + return $result +} diff --git a/powershell/public/Add-MtTestResultDetail.ps1 b/powershell/public/Add-MtTestResultDetail.ps1 index 93cff417e..290d70954 100644 --- a/powershell/public/Add-MtTestResultDetail.ps1 +++ b/powershell/public/Add-MtTestResultDetail.ps1 @@ -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, diff --git a/powershell/public/core/Connect-MtGitHub.ps1 b/powershell/public/core/Connect-MtGitHub.ps1 new file mode 100644 index 000000000..cb06bb7a7 --- /dev/null +++ b/powershell/public/core/Connect-MtGitHub.ps1 @@ -0,0 +1,172 @@ +function Connect-MtGitHub { + <# + .SYNOPSIS + Connects to the GitHub REST API for Maester security testing. + + .DESCRIPTION + Establishes a GitHub REST API session for Maester CIS GitHub Enterprise Cloud benchmark + tests. Validates the PAT via GET /user (token identity) and GET /orgs/{org} (org access). + + Token resolution order: + 1. -Token parameter (SecureString) + 2. MAESTER_GITHUB_TOKEN environment variable + 3. GH_TOKEN environment variable (GitHub CLI convention) + + Required permissions (classic PAT): admin:org + Fine-grained PAT (expected; validate in integration testing): + Organization Administration: read + Organization Members: read + Required GitHub role: organization owner for full org settings visibility. + + Note: Connection success proves token validity and org access, not that all + CIS control fields will be visible. Each CIS test validates field availability + and skips with an informative message if required fields are absent. + + .PARAMETER Organization + GitHub organization login name. Falls back to GitHubOrganization in maester-config.json. + + .PARAMETER Token + PAT as SecureString. Falls back to MAESTER_GITHUB_TOKEN or GH_TOKEN env vars. + + .PARAMETER ApiBaseUri + GitHub API base URI. Falls back to GitHubApiBaseUri config, then https://api.github.com. + Set to https://api.{subdomain}.ghe.com for GHE.com EMU deployments. + + .PARAMETER ApiVersion + GitHub REST API version date. Falls back to GitHubApiVersion config, then 2022-11-28. + GitHub defaults requests without X-GitHub-Api-Version to 2022-11-28. + + .EXAMPLE + Connect-MtGitHub -Organization 'mycompany' + + .EXAMPLE + Connect-MtGitHub -Organization 'mycompany' -ApiBaseUri 'https://api.myco.ghe.com' + + .LINK + https://maester.dev/docs/commands/Connect-MtGitHub + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Consistent with other Connect-* functions')] + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string] $Organization, + + [Parameter(Mandatory = $false)] + [securestring] $Token, + + [Parameter(Mandatory = $false)] + [ValidatePattern('^https://')] + [string] $ApiBaseUri, + + [Parameter(Mandatory = $false)] + [ValidatePattern('^\d{4}-\d{2}-\d{2}$')] + [string] $ApiVersion + ) + + # Clear prior GitHub session state (prevents stale data on reconnect) + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + + # Resolve organization (param -> config -> error) + if ([string]::IsNullOrWhiteSpace($Organization)) { + $Organization = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubOrganization' + } + if ([string]::IsNullOrWhiteSpace($Organization)) { + Write-Host "`nNo GitHub organization specified. Provide -Organization or set GitHubOrganization in maester-config.json." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'NotConfigured' } + return + } + + # Resolve ApiBaseUri (param -> config -> default) + if ([string]::IsNullOrWhiteSpace($ApiBaseUri)) { + $ApiBaseUri = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiBaseUri' + } + if ([string]::IsNullOrWhiteSpace($ApiBaseUri)) { $ApiBaseUri = 'https://api.github.com' } + $ApiBaseUri = $ApiBaseUri.TrimEnd('/') + + # Resolve ApiVersion (param -> config -> default) + if ([string]::IsNullOrWhiteSpace($ApiVersion)) { + $ApiVersion = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiVersion' + } + if ([string]::IsNullOrWhiteSpace($ApiVersion)) { $ApiVersion = '2022-11-28' } + + # Resolve token (param -> MAESTER_GITHUB_TOKEN -> GH_TOKEN) + $plainToken = $null + $bstr = [IntPtr]::Zero + try { + if ($Token) { + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Token) + $plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } elseif (-not [string]::IsNullOrEmpty($env:MAESTER_GITHUB_TOKEN)) { + $plainToken = $env:MAESTER_GITHUB_TOKEN + } elseif (-not [string]::IsNullOrEmpty($env:GH_TOKEN)) { + $plainToken = $env:GH_TOKEN + } + } finally { + if ($bstr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } + } + if ([string]::IsNullOrEmpty($plainToken)) { + Write-Host "`nNo GitHub token found. Provide -Token, or set MAESTER_GITHUB_TOKEN or GH_TOKEN." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'NoToken' } + return + } + + # Auth headers — token stored here, NOT in the connection object + $authHeaders = @{ + Authorization = "Bearer $plainToken" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = $ApiVersion + 'User-Agent' = 'Maester-GitHubCis' + } + Remove-Variable -Name plainToken -ErrorAction SilentlyContinue + + # Probe 1: token identity + try { + $userResponse = Invoke-WebRequest -Uri "$ApiBaseUri/user" -Headers $authHeaders -Method GET -UseBasicParsing -ErrorAction Stop + $user = $userResponse.Content | ConvertFrom-Json + Write-Verbose "GitHub token identity: $($user.login)" + } catch { + $code = Get-MtGitHubErrorStatusCode -ErrorRecord $_ + Write-Host "`nGitHub token validation failed (HTTP $code). Verify the PAT is valid and not expired." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'TokenInvalid' } + return + } + + # Probe 2: org access + $encodedOrg = [System.Uri]::EscapeDataString($Organization) + try { + $orgResponse = Invoke-WebRequest -Uri "$ApiBaseUri/orgs/$encodedOrg" -Headers $authHeaders -Method GET -UseBasicParsing -ErrorAction Stop + $orgData = $orgResponse.Content | ConvertFrom-Json + } catch { + $code = Get-MtGitHubErrorStatusCode -ErrorRecord $_ + $apiMsg = Get-MtGitHubErrorMessage -ErrorRecord $_ + $msg = switch ($code) { + 403 { "Access denied (403). Verify the PAT has 'admin:org' scope and is used by an organization owner. GitHub API: $apiMsg" } + 404 { "Organization '$Organization' not found (404). Check the organization login name. GitHub API: $apiMsg" } + default { "HTTP $code. $apiMsg" } + } + Write-Host "`nFailed to access GitHub organization: $msg" -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'OrgAccessFailed' } + return + } + + # Null-safe plan name (not visible for all token types/roles) + $planName = $null + if ($orgData.PSObject.Properties.Name -contains 'plan' -and $null -ne $orgData.plan) { + $planName = $orgData.plan.name + } + + $__MtSession.GitHubAuthHeader = $authHeaders + $__MtSession.GitHubConnection = [PSCustomObject]@{ + Connected = $true + Organization = $Organization + ApiBaseUri = $ApiBaseUri + ApiVersion = $ApiVersion + TokenLogin = $user.login + Plan = $planName + FailureReason = $null + } + + $planDisplay = if ($planName) { " (plan: $planName)" } else { '' } + Write-Host "Connected to GitHub organization '$($orgData.login)' as '$($user.login)'$planDisplay." -ForegroundColor Green +} diff --git a/powershell/public/core/Test-MtConnection.ps1 b/powershell/public/core/Test-MtConnection.ps1 index 3c2157426..908a71aaf 100644 --- a/powershell/public/core/Test-MtConnection.ps1 +++ b/powershell/public/core/Test-MtConnection.ps1 @@ -35,7 +35,7 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', 'AvoidUsingWriteHost', Justification = 'Sending colorful output to host in addition to rich object output.')] param( # Checks if the current session is connected to the specified service - [ValidateSet('All', 'Azure', 'AzureDevOps', 'ExchangeOnline', 'EOP', 'Graph', 'SecurityCompliance', 'Teams')] + [ValidateSet('All', 'Azure', 'AzureDevOps', 'ExchangeOnline', 'EOP', 'GitHub', 'Graph', 'SecurityCompliance', 'Teams')] [Parameter(Position = 0)] [string[]]$Service = 'Graph', @@ -49,6 +49,7 @@ PSTypeName = 'Maester.Connections' Azure = $null AzureDevOps = $null + GitHub = $null Graph = $null ExchangeOnline = $null ExchangeOnlineProtection = $null @@ -181,6 +182,23 @@ } #endregion AzureDevOps + #region GitHub + if ($Service -contains 'GitHub' -or $Service -contains 'All') { + $IsConnected = $false + if ($null -ne $__MtSession.GitHubConnection) { + if ($__MtSession.GitHubConnection.Connected -eq $true) { + $MtConnections.GitHub = $__MtSession.GitHubConnection + $IsConnected = $true + } + } else { + # No session state — GitHub requires explicit Connect-MtGitHub (no module auto-detect) + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'NotCalled' } + } + Write-Verbose "GitHub: $IsConnected" + if (!$IsConnected) { $ConnectionState = $false } + } + #endregion GitHub + $MtConnections.AllConnected = $ConnectionState } diff --git a/tests/maester-config.json b/tests/maester-config.json index e055960b7..4454e82ae 100644 --- a/tests/maester-config.json +++ b/tests/maester-config.json @@ -1,7 +1,10 @@ { "GlobalSettings": { "EmergencyAccessAccounts": [], - "DataverseEnvironmentUrl": "" + "DataverseEnvironmentUrl": "", + "GitHubOrganization": "", + "GitHubApiBaseUri": "https://api.github.com", + "GitHubApiVersion": "2022-11-28" }, "TestSettings": [ { From 358e69b9d34fe35db6271eff0ddd6faf7db42e88 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 02:16:19 +0000 Subject: [PATCH 02/24] test: add unit tests for Get-MtGitHubResponseHeaderValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all branching paths in the cross-version header extraction helper: null input, IDictionary path (PS 5.1 WebHeaderCollection — exact case, different case, array value, missing header), GetValues path (PS 7 HttpResponseHeaders — success and throw), and TryGetValues path (PS 7 — true and false return). Co-Authored-By: Claude Sonnet 4.6 --- .../Get-MtGitHubResponseHeaderValue.Tests.ps1 | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 powershell/tests/functions/Get-MtGitHubResponseHeaderValue.Tests.ps1 diff --git a/powershell/tests/functions/Get-MtGitHubResponseHeaderValue.Tests.ps1 b/powershell/tests/functions/Get-MtGitHubResponseHeaderValue.Tests.ps1 new file mode 100644 index 000000000..60e582b05 --- /dev/null +++ b/powershell/tests/functions/Get-MtGitHubResponseHeaderValue.Tests.ps1 @@ -0,0 +1,99 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Get-MtGitHubResponseHeaderValue' { + Context 'Null headers' { + It 'Returns $null when Headers is $null' { + InModuleScope Maester { + Get-MtGitHubResponseHeaderValue -Headers $null -Name 'x-ratelimit-remaining' | Should -BeNullOrEmpty + } + } + } + + Context 'IDictionary headers (PS 5.1 WebHeaderCollection style)' { + It 'Returns value for exact-case match' { + InModuleScope Maester { + $headers = @{ 'x-ratelimit-remaining' = '42' } + Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-remaining' | Should -Be '42' + } + } + + It 'Returns value for different-case header name' { + InModuleScope Maester { + $headers = @{ 'X-RateLimit-Remaining' = '10' } + Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-remaining' | Should -Be '10' + } + } + + It 'Returns first element when header value is an array' { + InModuleScope Maester { + $headers = @{ 'Link' = @('; rel="next"', '; rel="last"') } + $result = Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'Link' + $result | Should -Be '; rel="next"' + } + } + + It 'Returns $null when header is not present' { + InModuleScope Maester { + $headers = @{ 'content-type' = 'application/json' } + Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-remaining' | Should -BeNullOrEmpty + } + } + } + + Context 'HttpResponseHeaders with GetValues (PS 7 style)' { + It 'Returns value when GetValues succeeds' { + InModuleScope Maester { + $headers = [PSCustomObject]@{} + $headers | Add-Member -MemberType ScriptMethod -Name GetValues -Value { + param([string]$name) + if ($name -eq 'x-ratelimit-reset') { return @('1700000000') } + throw "Header '$name' not found" + } + $result = Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-reset' + $result | Should -Be '1700000000' + } + } + + It 'Returns $null when GetValues throws for unknown header' { + InModuleScope Maester { + $headers = [PSCustomObject]@{} + $headers | Add-Member -MemberType ScriptMethod -Name GetValues -Value { + param([string]$name) + throw "Header '$name' not found" + } + Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-remaining' | Should -BeNullOrEmpty + } + } + } + + Context 'HttpResponseHeaders with TryGetValues (PS 7 style)' { + It 'Returns value when TryGetValues succeeds' { + InModuleScope Maester { + $headers = [PSCustomObject]@{} + $headers | Add-Member -MemberType ScriptMethod -Name TryGetValues -Value { + param([string]$name, [ref]$values) + if ($name -eq 'retry-after') { + $values.Value = @('30') + return $true + } + return $false + } + $result = Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'retry-after' + $result | Should -Be '30' + } + } + + It 'Returns $null when TryGetValues returns false' { + InModuleScope Maester { + $headers = [PSCustomObject]@{} + $headers | Add-Member -MemberType ScriptMethod -Name TryGetValues -Value { + param([string]$name, [ref]$values) + return $false + } + Get-MtGitHubResponseHeaderValue -Headers $headers -Name 'x-ratelimit-remaining' | Should -BeNullOrEmpty + } + } + } +} From 0908399627a2c1469e4b534e70eb8da7d939ae5a Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 02:51:00 +0000 Subject: [PATCH 03/24] fix: preserve GitHub session across Invoke-Maester runs Clear-ModuleVariable nulled GitHubConnection and GitHubAuthHeader on every Invoke-Maester call, discarding any Connect-MtGitHub session established beforehand. Only GitHubCache needs to be reset per run, consistent with how $__MtSession.Connections (MS Graph) is handled. Co-Authored-By: Claude Sonnet 4.6 --- powershell/internal/Clear-ModuleVariable.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/powershell/internal/Clear-ModuleVariable.ps1 b/powershell/internal/Clear-ModuleVariable.ps1 index 253b22399..bb456aabd 100644 --- a/powershell/internal/Clear-ModuleVariable.ps1 +++ b/powershell/internal/Clear-ModuleVariable.ps1 @@ -21,8 +21,8 @@ Clear-MtExoCache $__MtSession.AIAgentInfo = $null $__MtSession.AzureDevOpsConnection = $null - $__MtSession.GitHubConnection = $null - $__MtSession.GitHubAuthHeader = $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. } From 3f7a77bde89c86a1cca2a30e3f332f1d67673d98 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 02:51:09 +0000 Subject: [PATCH 04/24] fix: lazy-load MaesterConfig in Connect-MtGitHub for pre-run invocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect-MtGitHub relied on Get-MtMaesterConfigGlobalSetting for the Organization, ApiBaseUri, and ApiVersion fallbacks, but MaesterConfig is only populated inside Invoke-Maester — after Clear-ModuleVariable has already run. Calling Connect-MtGitHub before Invoke-Maester (the documented flow) silently dropped all three config fallbacks. Add a lazy-load block that calls Get-MtMaesterConfig once when MaesterConfig is null and any config-backed parameter is omitted, covering all three values rather than Organization alone. Also fix a latent ValidatePattern bug: assigning $null from a config lookup directly to $ApiBaseUri or $ApiVersion (both declared with [ValidatePattern]) throws ValidationMetadataException inside the function body. Use local intermediate variables for the lookups and only update the parameter variable when the result is non-empty. Co-Authored-By: Claude Sonnet 4.6 --- powershell/public/core/Connect-MtGitHub.ps1 | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/powershell/public/core/Connect-MtGitHub.ps1 b/powershell/public/core/Connect-MtGitHub.ps1 index cb06bb7a7..0e5cd874e 100644 --- a/powershell/public/core/Connect-MtGitHub.ps1 +++ b/powershell/public/core/Connect-MtGitHub.ps1 @@ -67,6 +67,17 @@ $__MtSession.GitHubAuthHeader = $null $__MtSession.GitHubCache = @{} + # Lazy-load config once if MaesterConfig is not yet set and any config-backed parameter is + # omitted. This makes the config fallback work when Connect-MtGitHub is called before + # Invoke-Maester (the normal pre-run workflow). Get-MtMaesterConfig walks up to 5 parent + # directories from the given path, so it finds the config from anywhere in the test tree. + if ($null -eq $__MtSession.MaesterConfig -and ( + [string]::IsNullOrWhiteSpace($Organization) -or + [string]::IsNullOrWhiteSpace($ApiBaseUri) -or + [string]::IsNullOrWhiteSpace($ApiVersion))) { + $__MtSession.MaesterConfig = Get-MtMaesterConfig -Path (Get-Location).Path + } + # Resolve organization (param -> config -> error) if ([string]::IsNullOrWhiteSpace($Organization)) { $Organization = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubOrganization' @@ -78,15 +89,19 @@ } # Resolve ApiBaseUri (param -> config -> default) + # Use a local variable for the config lookup to avoid triggering [ValidatePattern] on $null. if ([string]::IsNullOrWhiteSpace($ApiBaseUri)) { - $ApiBaseUri = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiBaseUri' + $configApiBaseUri = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiBaseUri' + if (-not [string]::IsNullOrWhiteSpace($configApiBaseUri)) { $ApiBaseUri = $configApiBaseUri } } if ([string]::IsNullOrWhiteSpace($ApiBaseUri)) { $ApiBaseUri = 'https://api.github.com' } $ApiBaseUri = $ApiBaseUri.TrimEnd('/') # Resolve ApiVersion (param -> config -> default) + # Use a local variable for the config lookup to avoid triggering [ValidatePattern] on $null. if ([string]::IsNullOrWhiteSpace($ApiVersion)) { - $ApiVersion = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiVersion' + $configApiVersion = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiVersion' + if (-not [string]::IsNullOrWhiteSpace($configApiVersion)) { $ApiVersion = $configApiVersion } } if ([string]::IsNullOrWhiteSpace($ApiVersion)) { $ApiVersion = '2022-11-28' } From 8db4bedf613a3b2914ec26f936dedbcdf1646e86 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 02:51:19 +0000 Subject: [PATCH 05/24] test: add unit tests for GitHub connection lifecycle and request wrapper Clear-ModuleVariable: regression guard verifying GitHubConnection and GitHubAuthHeader survive Invoke-Maester's run-start reset while GitHubCache is cleared (Count -eq 0) and Test-MtConnection GitHub still returns true. Connect-MtGitHub: covers NotConfigured, NoToken, TokenInvalid, and OrgAccessFailed (403 and 404) failure modes; successful connection with probe URI and X-GitHub-Api-Version header assertions; config resolution from pre-loaded MaesterConfig without calling Get-MtMaesterConfig; and lazy-load paths for all three config-backed parameters (Organization, ApiBaseUri, ApiVersion). Invoke-MtGitHubRequest: covers connection guard (null and false), cache hit (web request called once for two identical calls), cache store (key verified as "$version|$absUri"), -DisableCache bypass without overwriting an existing entry, single-page and paginated (Link rel=next) responses, verbose rate-limit warning at remaining=0, primary rate-limit throw (403/429), and secondary rate-limit throw. Co-Authored-By: Claude Sonnet 4.6 --- .../functions/Clear-ModuleVariable.Tests.ps1 | 51 ++++ .../functions/Connect-MtGitHub.Tests.ps1 | 224 ++++++++++++++++++ .../Invoke-MtGitHubRequest.Tests.ps1 | 171 +++++++++++++ 3 files changed, 446 insertions(+) create mode 100644 powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 create mode 100644 powershell/tests/functions/Connect-MtGitHub.Tests.ps1 create mode 100644 powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 diff --git a/powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 b/powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 new file mode 100644 index 000000000..f43680c4e --- /dev/null +++ b/powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 @@ -0,0 +1,51 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Clear-ModuleVariable — GitHub session lifecycle' { + BeforeEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer testtoken' } + $__MtSession.GitHubCache = @{ somekey = 'cached-value' } + } + } + + AfterEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + } + } + + It 'Preserves GitHubConnection after Clear-ModuleVariable' { + InModuleScope Maester { + Clear-ModuleVariable + $__MtSession.GitHubConnection | Should -Not -BeNullOrEmpty + $__MtSession.GitHubConnection.Connected | Should -BeTrue + } + } + + It 'Preserves GitHubAuthHeader after Clear-ModuleVariable' { + InModuleScope Maester { + Clear-ModuleVariable + $__MtSession.GitHubAuthHeader | Should -Not -BeNullOrEmpty + $__MtSession.GitHubAuthHeader['Authorization'] | Should -Be 'Bearer testtoken' + } + } + + It 'Resets GitHubCache to empty after Clear-ModuleVariable' { + InModuleScope Maester { + Clear-ModuleVariable + $__MtSession.GitHubCache.Count | Should -Be 0 + } + } + + It 'Test-MtConnection GitHub still returns True after Clear-ModuleVariable' { + InModuleScope Maester { + Clear-ModuleVariable + Test-MtConnection -Service GitHub | Should -BeTrue + } + } +} diff --git a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 new file mode 100644 index 000000000..b1c1a6b89 --- /dev/null +++ b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 @@ -0,0 +1,224 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Connect-MtGitHub' { + BeforeEach { + $script:savedMaesterGitHubToken = $env:MAESTER_GITHUB_TOKEN + $script:savedGhToken = $env:GH_TOKEN + $env:MAESTER_GITHUB_TOKEN = $null + $env:GH_TOKEN = $null + + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + # Pre-load an empty config so the lazy-load path is skipped in most tests. + # Tests that exercise lazy-load explicitly reset this to $null. + $__MtSession.MaesterConfig = [PSCustomObject]@{ + GlobalSettings = [PSCustomObject]@{} + } + } + } + + AfterEach { + $env:MAESTER_GITHUB_TOKEN = $script:savedMaesterGitHubToken + $env:GH_TOKEN = $script:savedGhToken + + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + $__MtSession.MaesterConfig = $null + } + } + + Context 'Failure: NotConfigured' { + It 'Sets FailureReason = NotConfigured when no org and config has no GitHubOrganization' { + Connect-MtGitHub + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeFalse + $__MtSession.GitHubConnection.FailureReason | Should -Be 'NotConfigured' + } + } + } + + Context 'Failure: NoToken' { + It 'Sets FailureReason = NoToken when org is given but no token is available' { + # Token env vars are cleared in BeforeEach; no -Token param + Connect-MtGitHub -Organization 'myorg' + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeFalse + $__MtSession.GitHubConnection.FailureReason | Should -Be 'NoToken' + } + } + } + + Context 'Failure: TokenInvalid' { + It 'Sets FailureReason = TokenInvalid on HTTP 401 from /user' { + $env:MAESTER_GITHUB_TOKEN = 'bad-token' + $fakeResp = [PSCustomObject]@{ StatusCode = 401; Headers = @{} } + $ex = [System.Exception]::new('Unauthorized') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } + + Connect-MtGitHub -Organization 'myorg' + + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeFalse + $__MtSession.GitHubConnection.FailureReason | Should -Be 'TokenInvalid' + } + } + } + + Context 'Failure: OrgAccessFailed' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + } + + It 'Sets FailureReason = OrgAccessFailed on HTTP 403 from /orgs/{org}' { + $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } + $ex = [System.Exception]::new('Forbidden') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { throw $ex } + + Connect-MtGitHub -Organization 'myorg' + + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeFalse + $__MtSession.GitHubConnection.FailureReason | Should -Be 'OrgAccessFailed' + } + } + + It 'Sets FailureReason = OrgAccessFailed on HTTP 404 from /orgs/{org}' { + $fakeResp = [PSCustomObject]@{ StatusCode = 404; Headers = @{} } + $ex = [System.Exception]::new('Not Found') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { throw $ex } + + Connect-MtGitHub -Organization 'myorg' + + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeFalse + $__MtSession.GitHubConnection.FailureReason | Should -Be 'OrgAccessFailed' + } + } + } + + Context 'Successful connection' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { + [PSCustomObject]@{ Content = '{"login":"myorg","plan":{"name":"enterprise"}}' } + } + } + + It 'Sets Connected = $true and stores GitHubAuthHeader' { + Connect-MtGitHub -Organization 'myorg' + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeTrue + $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' + $__MtSession.GitHubConnection.TokenLogin | Should -Be 'testuser' + $__MtSession.GitHubAuthHeader | Should -Not -BeNullOrEmpty + $__MtSession.GitHubAuthHeader['Authorization'] | Should -Match '^Bearer ' + } + } + + It 'Both probes use the configured ApiBaseUri and X-GitHub-Api-Version header' { + Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.myco.ghe.com' -ApiVersion '2024-01-01' + + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 2 -ParameterFilter { + $Uri -match 'api\.myco\.ghe\.com' -and $Headers['X-GitHub-Api-Version'] -eq '2024-01-01' + } + } + } + + Context 'Config fallback: pre-loaded MaesterConfig' { + It 'Resolves org from pre-loaded MaesterConfig without calling Get-MtMaesterConfig' { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + InModuleScope Maester { + $__MtSession.MaesterConfig = [PSCustomObject]@{ + GlobalSettings = [PSCustomObject]@{ + GitHubOrganization = 'config-org' + } + } + } + Mock Get-MtMaesterConfig -ModuleName Maester { throw 'Get-MtMaesterConfig must not be called when config is pre-loaded' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { + [PSCustomObject]@{ Content = '{"login":"config-org"}' } + } + + Connect-MtGitHub + + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeTrue + $__MtSession.GitHubConnection.Organization | Should -Be 'config-org' + } + } + } + + Context 'Config fallback: lazy-load when MaesterConfig is null' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + + # Reset to null so the lazy-load path fires + InModuleScope Maester { $__MtSession.MaesterConfig = $null } + + $fakeConfig = [PSCustomObject]@{ + GlobalSettings = [PSCustomObject]@{ + GitHubOrganization = 'lazy-org' + GitHubApiBaseUri = 'https://api.lazy.ghe.com' + GitHubApiVersion = '2024-06-01' + } + } + Mock Get-MtMaesterConfig -ModuleName Maester { $fakeConfig } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { + [PSCustomObject]@{ Content = '{"login":"lazy-org"}' } + } + } + + It 'Lazy-loads config and resolves org when MaesterConfig is null and no -Organization supplied' { + Connect-MtGitHub + + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeTrue + $__MtSession.GitHubConnection.Organization | Should -Be 'lazy-org' + # $__MtSession.MaesterConfig is now set; real Get-MtMaesterConfigGlobalSetting reads it + $__MtSession.MaesterConfig.GlobalSettings.GitHubOrganization | Should -Be 'lazy-org' + } + } + + It 'Lazy-loads config for ApiBaseUri and ApiVersion when -Organization is supplied but others are omitted' { + Connect-MtGitHub -Organization 'myorg' + + InModuleScope Maester { + $__MtSession.GitHubConnection.Connected | Should -BeTrue + $__MtSession.GitHubConnection.ApiBaseUri | Should -Be 'https://api.lazy.ghe.com' + $__MtSession.GitHubConnection.ApiVersion | Should -Be '2024-06-01' + } + } + + It 'All three config-backed values (org, ApiBaseUri, ApiVersion) are resolved from lazy-loaded config' { + Connect-MtGitHub + + InModuleScope Maester { + $conn = $__MtSession.GitHubConnection + $conn.Organization | Should -Be 'lazy-org' + $conn.ApiBaseUri | Should -Be 'https://api.lazy.ghe.com' + $conn.ApiVersion | Should -Be '2024-06-01' + } + } + } +} diff --git a/powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 b/powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 new file mode 100644 index 000000000..48fc520c9 --- /dev/null +++ b/powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 @@ -0,0 +1,171 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Invoke-MtGitHubRequest' { + BeforeEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ + Connected = $true + Organization = 'myorg' + ApiBaseUri = 'https://api.github.com' + ApiVersion = '2022-11-28' + } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer faketoken' } + $__MtSession.GitHubCache = @{} + } + } + + AfterEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + } + } + + Context 'Connection guard' { + It 'Throws when GitHubConnection is null' { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*Connect-MtGitHub*' + } + } + + It 'Throws when Connected = $false' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false } + { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*Connect-MtGitHub*' + } + } + } + + Context 'Cache behavior' { + BeforeEach { + Mock Invoke-WebRequest -ModuleName Maester { + [PSCustomObject]@{ Content = '{"login":"myorg"}'; Headers = @{} } + } + } + + It 'Returns cached result; Invoke-WebRequest called only once for two identical calls' { + InModuleScope Maester { + Invoke-MtGitHubRequest '/orgs/myorg' | Out-Null + Invoke-MtGitHubRequest '/orgs/myorg' | Out-Null + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 1 + } + + It 'Cache key is ApiVersion|absoluteUri' { + InModuleScope Maester { + Invoke-MtGitHubRequest '/orgs/myorg' | Out-Null + $expectedKey = '2022-11-28|https://api.github.com/orgs/myorg' + $__MtSession.GitHubCache.ContainsKey($expectedKey) | Should -BeTrue + } + } + + It 'Stores result in cache on successful call (no -DisableCache)' { + InModuleScope Maester { + $result = Invoke-MtGitHubRequest '/orgs/myorg' + $result.login | Should -Be 'myorg' + $__MtSession.GitHubCache.Count | Should -Be 1 + $cacheKey = '2022-11-28|https://api.github.com/orgs/myorg' + $__MtSession.GitHubCache[$cacheKey].login | Should -Be 'myorg' + } + } + + It '-DisableCache bypasses existing cache entry and does NOT store result' { + InModuleScope Maester { + $cacheKey = '2022-11-28|https://api.github.com/orgs/myorg' + $__MtSession.GitHubCache[$cacheKey] = [PSCustomObject]@{ login = 'cached-value' } + + $result = Invoke-MtGitHubRequest '/orgs/myorg' -DisableCache + + # Web request was made (bypassed cache) + $result.login | Should -Be 'myorg' + # Original cache entry is untouched (not overwritten) + $__MtSession.GitHubCache[$cacheKey].login | Should -Be 'cached-value' + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 1 + } + } + + Context 'Non-paginated request' { + It 'Returns single object body without -Paginate' { + Mock Invoke-WebRequest -ModuleName Maester { + [PSCustomObject]@{ Content = '{"login":"myorg","plan":{"name":"enterprise"}}'; Headers = @{} } + } + InModuleScope Maester { + $result = Invoke-MtGitHubRequest '/orgs/myorg' + $result.login | Should -Be 'myorg' + } + } + } + + Context 'Pagination' { + It 'Follows Link rel=next until no next link; returns all items combined' { + # [?&]page= matches ?page= or &page= but NOT per_page= (per_page contains 'page=' at offset 4) + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -notmatch '[?&]page=\d' } { + [PSCustomObject]@{ + Content = '[{"id":1},{"id":2}]' + Headers = @{ 'Link' = '; rel="next"' } + } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '[?&]page=\d' } { + [PSCustomObject]@{ Content = '[{"id":3}]'; Headers = @{} } + } + + InModuleScope Maester { + $result = Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate + $result.Count | Should -Be 3 + $result[2].id | Should -Be 3 + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 2 + } + } + + Context 'Rate limit handling' { + It 'Emits verbose message when x-ratelimit-remaining is 0 on successful response' { + Mock Invoke-WebRequest -ModuleName Maester { + [PSCustomObject]@{ + Content = '{"login":"myorg"}' + Headers = @{ 'x-ratelimit-remaining' = '0'; 'x-ratelimit-reset' = '9999999999' } + } + } + InModuleScope Maester { + $allOutput = Invoke-MtGitHubRequest '/orgs/myorg' -Verbose 4>&1 + $verboseMsgs = ($allOutput | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }).Message + $verboseMsgs | Should -Match 'rate limit' + } + } + + It 'Throws on primary rate-limit exhaustion (HTTP 403, remaining = 0)' { + Mock Invoke-WebRequest -ModuleName Maester { + $fakeResp = [PSCustomObject]@{ + StatusCode = 403 + Headers = @{ 'x-ratelimit-remaining' = '0'; 'x-ratelimit-reset' = '9999999999' } + } + $ex = [System.Exception]::new('Forbidden') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + throw $ex + } + InModuleScope Maester { + { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*rate limit*' + } + } + + It 'Throws on secondary rate-limit (HTTP 429, retry-after header present)' { + Mock Invoke-WebRequest -ModuleName Maester { + $fakeResp = [PSCustomObject]@{ + StatusCode = 429 + Headers = @{ 'retry-after' = '30' } + } + $ex = [System.Exception]::new('Too Many Requests') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + throw $ex + } + InModuleScope Maester { + { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*secondary rate limit*' + } + } + } +} From 9104f63bcb1bd0a5c17dd52fce1aab86715f677a Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 16:46:53 +0000 Subject: [PATCH 06/24] =?UTF-8?q?fix:=20address=20GitHub=20review=20findin?= =?UTF-8?q?gs=20=E2=80=94=20help,=20error=20messages,=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GitHub to Test-MtConnection comment-based help: valid service list, behavior note (explicit Connect-MtGitHub required, treated as required in -Service All), updated example descriptions, new -Service GitHub example - Improve Get-MtGitHubErrorMessage to extract the JSON .message field from GitHub REST error bodies so failures show clean strings instead of raw JSON - Add Test-MtConnection.Tests.ps1 with 7 GitHub-focused unit tests covering null/sentinel/disconnected/connected states and -Details/-Service All paths Co-Authored-By: Claude Sonnet 4.6 --- .../internal/Get-MtGitHubErrorMessage.ps1 | 9 ++ powershell/public/core/Test-MtConnection.ps1 | 14 ++- .../functions/Test-MtConnection.Tests.ps1 | 87 +++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 powershell/tests/functions/Test-MtConnection.Tests.ps1 diff --git a/powershell/internal/Get-MtGitHubErrorMessage.ps1 b/powershell/internal/Get-MtGitHubErrorMessage.ps1 index 508196580..6ce0b5659 100644 --- a/powershell/internal/Get-MtGitHubErrorMessage.ps1 +++ b/powershell/internal/Get-MtGitHubErrorMessage.ps1 @@ -1,6 +1,15 @@ 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)) { diff --git a/powershell/public/core/Test-MtConnection.ps1 b/powershell/public/core/Test-MtConnection.ps1 index 908a71aaf..483c4b3f2 100644 --- a/powershell/public/core/Test-MtConnection.ps1 +++ b/powershell/public/core/Test-MtConnection.ps1 @@ -7,7 +7,9 @@ Tests the connection for each service and returns $true if the session is connected to the specified service. .PARAMETER Service - The service to check the connection for. Valid values are 'All', 'Azure', 'AzureDevOps', 'ExchangeOnline', 'Graph', 'SecurityCompliance' (or 'EOP'), and 'Teams'. Default is 'Graph'. + The service to check the connection for. Valid values are 'All', 'Azure', 'AzureDevOps', 'ExchangeOnline', 'GitHub', 'Graph', 'SecurityCompliance' (or 'EOP'), and 'Teams'. Default is 'Graph'. + + GitHub requires an explicit Connect-MtGitHub call before testing — unlike other services it has no auto-detection. When checked via -Service All, GitHub is treated as required and will return $false if Connect-MtGitHub has not been called. .PARAMETER Details Return the full details of all connections instead of just a boolean value. @@ -15,18 +17,24 @@ .EXAMPLE Test-MtConnection -Service All - Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), and Microsoft Teams. Returns a Boolean value. + Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), Microsoft Teams, and GitHub (if Connect-MtGitHub was called). Returns a Boolean value. .EXAMPLE Test-MtConnection -Service All -Details - Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), and Microsoft Teams. Returns a custom object that contains the connection details for all services. + Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), Microsoft Teams, and GitHub (if Connect-MtGitHub was called). Returns a custom object that contains the connection details for all services. .EXAMPLE Test-MtConnection -Service Azure Checks if the current session is connected to Azure and returns a Boolean result. + .EXAMPLE + Test-MtConnection -Service GitHub + + Checks if the current session is connected to GitHub and returns a Boolean result. + Returns $false if Connect-MtGitHub has not been called in this session. + .LINK https://maester.dev/docs/commands/Test-MtConnection #> diff --git a/powershell/tests/functions/Test-MtConnection.Tests.ps1 b/powershell/tests/functions/Test-MtConnection.Tests.ps1 new file mode 100644 index 000000000..33561ce1a --- /dev/null +++ b/powershell/tests/functions/Test-MtConnection.Tests.ps1 @@ -0,0 +1,87 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Test-MtConnection — GitHub service' { + BeforeEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + } + } + + AfterEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + } + } + + Context 'When Connect-MtGitHub has never been called' { + It 'Returns $false for -Service GitHub' { + InModuleScope Maester { + Test-MtConnection -Service GitHub | Should -BeFalse + } + } + + It 'Writes the NotCalled sentinel to GitHubConnection' { + InModuleScope Maester { + Test-MtConnection -Service GitHub | Out-Null + $__MtSession.GitHubConnection | Should -Not -BeNullOrEmpty + $__MtSession.GitHubConnection.FailureReason | Should -Be 'NotCalled' + } + } + } + + Context 'When GitHub is explicitly disconnected' { + It 'Returns $false when Connected is $false' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'TokenInvalid' } + Test-MtConnection -Service GitHub | Should -BeFalse + } + } + } + + Context 'When GitHub is connected' { + It 'Returns $true' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + Test-MtConnection -Service GitHub | Should -BeTrue + } + } + + It 'Returns an object with GitHub populated and AllConnected $true when using -Details' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $result = Test-MtConnection -Service GitHub -Details + $result.GitHub | Should -Not -BeNullOrEmpty + $result.GitHub.Connected | Should -BeTrue + $result.AllConnected | Should -BeTrue + } + } + } + + Context 'When GitHub is not connected' { + It 'Returns an object with GitHub $null and AllConnected $false when using -Details' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'NoToken' } + $result = Test-MtConnection -Service GitHub -Details + $result.GitHub | Should -BeNullOrEmpty + $result.AllConnected | Should -BeFalse + } + } + } + + Context '-Service All -Details with GitHub connected' { + It 'Populates the GitHub property even when other services are absent' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + # Other services (Azure, Graph, etc.) are not connected in unit test scope. + # AllConnected will be $false due to missing services, but GitHub must be set. + $result = Test-MtConnection -Service All -Details + $result.GitHub | Should -Not -BeNullOrEmpty + $result.GitHub.Connected | Should -BeTrue + # AllConnected reflects ALL services; other services will be absent here + $result.AllConnected | Should -BeFalse + } + } + } +} From b35b7142e2772c990e7713c411be42367f1f1056 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 23:50:48 +0000 Subject: [PATCH 07/24] fix: make GitHub opt-in for Test-MtConnection -Service All GitHub was unconditionally entered when -Service All was used, stamping a NotCalled sentinel and setting ConnectionState = $false whenever Connect-MtGitHub had not been called. This silently broke existing users relying on -Service All for Microsoft services only. GitHub is now skipped under All unless a real connection attempt has been made (GitHubConnection is non-null and FailureReason is not 'NotCalled'). Explicit -Service GitHub remains strict. Update docstrings and examples to match the new opt-in semantics. Co-Authored-By: Claude Sonnet 4.6 --- powershell/public/core/Test-MtConnection.ps1 | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/powershell/public/core/Test-MtConnection.ps1 b/powershell/public/core/Test-MtConnection.ps1 index 483c4b3f2..f6de34916 100644 --- a/powershell/public/core/Test-MtConnection.ps1 +++ b/powershell/public/core/Test-MtConnection.ps1 @@ -9,7 +9,7 @@ .PARAMETER Service The service to check the connection for. Valid values are 'All', 'Azure', 'AzureDevOps', 'ExchangeOnline', 'GitHub', 'Graph', 'SecurityCompliance' (or 'EOP'), and 'Teams'. Default is 'Graph'. - GitHub requires an explicit Connect-MtGitHub call before testing — unlike other services it has no auto-detection. When checked via -Service All, GitHub is treated as required and will return $false if Connect-MtGitHub has not been called. + GitHub requires an explicit Connect-MtGitHub call before testing — unlike other services it has no auto-detection. When checked via -Service All, GitHub is only evaluated if Connect-MtGitHub has been called — if no GitHub session exists it is silently skipped and does not affect the result. .PARAMETER Details Return the full details of all connections instead of just a boolean value. @@ -17,12 +17,12 @@ .EXAMPLE Test-MtConnection -Service All - Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), Microsoft Teams, and GitHub (if Connect-MtGitHub was called). Returns a Boolean value. + Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), Microsoft Teams, and GitHub (only if Connect-MtGitHub was called). Returns a Boolean value. .EXAMPLE Test-MtConnection -Service All -Details - Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), Microsoft Teams, and GitHub (if Connect-MtGitHub was called). Returns a custom object that contains the connection details for all services. + Checks if the current session is connected to all services including Azure, Microsoft Graph, Exchange Online, Exchange Online Protection (SecurityCompliance), Microsoft Teams, and GitHub (only if Connect-MtGitHub was called). Returns a custom object that contains the connection details for all services. .EXAMPLE Test-MtConnection -Service Azure @@ -191,7 +191,11 @@ #endregion AzureDevOps #region GitHub - if ($Service -contains 'GitHub' -or $Service -contains 'All') { + if ($Service -contains 'GitHub' -or ( + $Service -contains 'All' -and + $null -ne $__MtSession.GitHubConnection -and + $__MtSession.GitHubConnection.FailureReason -ne 'NotCalled' + )) { $IsConnected = $false if ($null -ne $__MtSession.GitHubConnection) { if ($__MtSession.GitHubConnection.Connected -eq $true) { From eea8b0824073f4f8b35abef036702472e5c11c8d Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Thu, 7 May 2026 23:51:46 +0000 Subject: [PATCH 08/24] test: replace slow live-probe All test with focused mocked suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous -Service All -Details test hit real cmdlets (Get-AzContext, Get-MgContext, Get-MtExo, Get-CsTenant, Get-ADOPSConnection), taking 60+ seconds and emitting Azure token-cache warnings. Replace with four mocked tests using Mock -ModuleName Maester: - -Service GitHub -Details: details-object shape when GitHub connected - Regression test: all MS services mocked connected + GitHub null → AllConnected must be $true (proves the fix, not just property absence) - NotCalled sentinel: GitHub skipped even when sentinel exists from a prior -Service GitHub probe - GitHub connected: property populated when Connect-MtGitHub was called Also reset AzureDevOpsConnection in BeforeEach/AfterEach to prevent state leak between tests. Suite now completes in ~7s. Co-Authored-By: Claude Sonnet 4.6 --- .../functions/Test-MtConnection.Tests.ps1 | 77 ++++++++++++++++--- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/powershell/tests/functions/Test-MtConnection.Tests.ps1 b/powershell/tests/functions/Test-MtConnection.Tests.ps1 index 33561ce1a..b1e6c3e1e 100644 --- a/powershell/tests/functions/Test-MtConnection.Tests.ps1 +++ b/powershell/tests/functions/Test-MtConnection.Tests.ps1 @@ -5,13 +5,15 @@ BeforeAll { Describe 'Test-MtConnection — GitHub service' { BeforeEach { InModuleScope Maester { - $__MtSession.GitHubConnection = $null + $__MtSession.GitHubConnection = $null + $__MtSession.AzureDevOpsConnection = $null } } AfterEach { InModuleScope Maester { - $__MtSession.GitHubConnection = $null + $__MtSession.GitHubConnection = $null + $__MtSession.AzureDevOpsConnection = $null } } @@ -70,18 +72,71 @@ Describe 'Test-MtConnection — GitHub service' { } } - Context '-Service All -Details with GitHub connected' { - It 'Populates the GitHub property even when other services are absent' { + Context '-Service GitHub -Details with GitHub connected' { + It 'Populates the GitHub property and returns AllConnected $true' { InModuleScope Maester { $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } - # Other services (Azure, Graph, etc.) are not connected in unit test scope. - # AllConnected will be $false due to missing services, but GitHub must be set. - $result = Test-MtConnection -Service All -Details - $result.GitHub | Should -Not -BeNullOrEmpty - $result.GitHub.Connected | Should -BeTrue - # AllConnected reflects ALL services; other services will be absent here - $result.AllConnected | Should -BeFalse } + $result = Test-MtConnection -Service GitHub -Details + $result.GitHub | Should -Not -BeNullOrEmpty + $result.GitHub.Connected | Should -BeTrue + $result.AllConnected | Should -BeTrue + } + } + + Context '-Service All regression — GitHub absence does not flip AllConnected' { + It 'Returns AllConnected $true when all MS services are connected and GitHub session is absent' { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + $__MtSession.AzureDevOpsConnection = [PSCustomObject]@{ Organization = 'ado-org' } + } + Mock Get-AzContext { [PSCustomObject]@{ Account = 'test@contoso.com' } } -ModuleName Maester + Mock Invoke-AzRestMethod { [PSCustomObject]@{} } -ModuleName Maester + Mock Get-MgContext { [PSCustomObject]@{ TenantId = 'tenant-id' } } -ModuleName Maester + Mock Get-MtExo { + @( + [PSCustomObject]@{ Name = 'ExchangeOnline'; State = 'Connected'; IsEopSession = $false } + [PSCustomObject]@{ Name = 'ExchangeOnline'; State = 'Connected'; IsEopSession = $true } + ) + } -ModuleName Maester + Mock Get-CsTenant { [PSCustomObject]@{ TenantId = 'tenant-id' } } -ModuleName Maester + $result = Test-MtConnection -Service All -Details + $result.AllConnected | Should -BeTrue + $result.GitHub | Should -BeNullOrEmpty + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -BeNullOrEmpty + } + } + } + + Context '-Service All — GitHub skipped when NotCalled sentinel exists' { + It 'Does not set the GitHub property' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'NotCalled' } + $__MtSession.AzureDevOpsConnection = 'NotConnected' + } + Mock Get-AzContext { $null } -ModuleName Maester + Mock Get-MgContext { $null } -ModuleName Maester + Mock Get-MtExo { $null } -ModuleName Maester + Mock Get-CsTenant { $null } -ModuleName Maester + $result = Test-MtConnection -Service All -Details + $result.GitHub | Should -BeNullOrEmpty + } + } + + Context '-Service All — GitHub included when connected' { + It 'Populates the GitHub property' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.AzureDevOpsConnection = 'NotConnected' + } + Mock Get-AzContext { $null } -ModuleName Maester + Mock Get-MgContext { $null } -ModuleName Maester + Mock Get-MtExo { $null } -ModuleName Maester + Mock Get-CsTenant { $null } -ModuleName Maester + $result = Test-MtConnection -Service All -Details + $result.GitHub | Should -Not -BeNullOrEmpty + $result.GitHub.Connected | Should -BeTrue } } } From 03125acc414c47324c68e439c4d7f625c0458f29 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Fri, 8 May 2026 00:39:58 +0000 Subject: [PATCH 09/24] =?UTF-8?q?fix:=20address=20GitHub=20review=20findin?= =?UTF-8?q?gs=20=E2=80=94=20token=20redaction,=20role=20probe,=20test=20pe?= =?UTF-8?q?rf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Get-MtSession: redact GitHubAuthHeader Authorization on output (case-insensitive, fail-closed for non-dictionary shapes); live session unchanged so internal callers still work. Prevents PAT disclosure in troubleshooting dumps. - Add Disconnect-MtGitHub to clear GitHub session state; export from manifest. - Disconnect-Maester (and Disconnect-MtMaester alias) now also clears GitHub state; Disconnect-MtGraph alias keeps its narrow Graph-only semantic. - Connect-MtGitHub: add /orgs/{org}/memberships/{user} probe to verify org role. New fields Role, RoleState, RoleVerified, RoleVerificationFailureReason distinguish "known non-admin" from "could not check". Warns on member/pending/ probe-failure/malformed-body without blocking connection. - Test-MtConnection.Tests.ps1: stub MS service cmdlets in BeforeAll when absent (cleaned up in AfterAll), bypassing Pester's slow command-resolution path. Cuts the -Service All regression test from ~63s to ~180ms. Co-Authored-By: Claude Opus 4.7 --- powershell/Maester.psd1 | 2 +- powershell/public/Disconnect-Maester.ps1 | 9 + powershell/public/core/Connect-MtGitHub.ps1 | 81 +++++++- .../public/core/Disconnect-MtGitHub.ps1 | 38 ++++ powershell/public/core/Get-MtSession.ps1 | 30 ++- .../functions/Connect-MtGitHub.Tests.ps1 | 195 +++++++++++++++++- .../functions/Disconnect-Maester.Tests.ps1 | 76 +++++++ .../functions/Disconnect-MtGitHub.Tests.ps1 | 51 +++++ .../tests/functions/Get-MtSession.Tests.ps1 | 143 +++++++++++++ .../functions/Test-MtConnection.Tests.ps1 | 17 ++ 10 files changed, 620 insertions(+), 22 deletions(-) create mode 100644 powershell/public/core/Disconnect-MtGitHub.ps1 create mode 100644 powershell/tests/functions/Disconnect-Maester.Tests.ps1 create mode 100644 powershell/tests/functions/Disconnect-MtGitHub.Tests.ps1 create mode 100644 powershell/tests/functions/Get-MtSession.Tests.ps1 diff --git a/powershell/Maester.psd1 b/powershell/Maester.psd1 index 2376e8829..2a9110241 100644 --- a/powershell/Maester.psd1 +++ b/powershell/Maester.psd1 @@ -59,7 +59,7 @@ 'Add-MtMaesterAppFederatedCredential', 'Add-MtTestResultDetail', 'Clear-MtDnsCache', 'Clear-MtExoCache', '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', diff --git a/powershell/public/Disconnect-Maester.ps1 b/powershell/public/Disconnect-Maester.ps1 index 8918dd355..09cbd6473 100644 --- a/powershell/public/Disconnect-Maester.ps1 +++ b/powershell/public/Disconnect-Maester.ps1 @@ -11,6 +11,10 @@ Disconnect-MgGraph ``` + When invoked as Disconnect-Maester or Disconnect-MtMaester, also clears any active GitHub + REST session (token, connection metadata, per-session cache). The Disconnect-MtGraph alias + keeps its narrow Graph-only semantic and does NOT clear GitHub state. + .Example Disconnect-MtGraph @@ -49,4 +53,9 @@ Write-Verbose -Message "Disconnecting from Microsoft Teams." Disconnect-MicrosoftTeams } + + $invokedAs = $MyInvocation.InvocationName + if ($invokedAs -iin @('Disconnect-Maester','Disconnect-MtMaester')) { + Disconnect-MtGitHub + } } diff --git a/powershell/public/core/Connect-MtGitHub.ps1 b/powershell/public/core/Connect-MtGitHub.ps1 index 0e5cd874e..2a409766d 100644 --- a/powershell/public/core/Connect-MtGitHub.ps1 +++ b/powershell/public/core/Connect-MtGitHub.ps1 @@ -5,7 +5,16 @@ .DESCRIPTION Establishes a GitHub REST API session for Maester CIS GitHub Enterprise Cloud benchmark - tests. Validates the PAT via GET /user (token identity) and GET /orgs/{org} (org access). + tests. Validates the PAT via three probes: + 1. GET /user — token identity + 2. GET /orgs/{org} — org access + 3. GET /orgs/{org}/memberships/{user} — role check (admin vs member, active vs pending) + + The third probe populates Role, RoleState, RoleVerified, and RoleVerificationFailureReason + on the connection object. RoleVerified = $true with Role = 'admin' and RoleState = 'active' + is the no-warning path; anything else (member role, pending state, or a probe failure) + emits a warning. A failed role probe does NOT block connection — token validity and org + access are already proven. Token resolution order: 1. -Token parameter (SecureString) @@ -171,15 +180,71 @@ $planName = $orgData.plan.name } + # Probe 3: org membership / role + # Distinguishes "known non-admin" (RoleVerified=$true, Role!='admin') from + # "could not check" (RoleVerified=$false). Failures here never block connection. + $role = $null + $roleState = $null + $roleVerified = $false + $roleVerificationFailureReason = $null + $roleWarning = $null + + $encodedLogin = [System.Uri]::EscapeDataString($user.login) + try { + $membershipResponse = Invoke-WebRequest -Uri "$ApiBaseUri/orgs/$encodedOrg/memberships/$encodedLogin" -Headers $authHeaders -Method GET -UseBasicParsing -ErrorAction Stop + + $membershipData = $null + try { + $membershipData = $membershipResponse.Content | ConvertFrom-Json -ErrorAction Stop + } catch { + $membershipData = $null + } + + $hasState = $null -ne $membershipData -and $membershipData.PSObject.Properties.Name -contains 'state' -and -not [string]::IsNullOrWhiteSpace([string]$membershipData.state) + $hasRole = $null -ne $membershipData -and $membershipData.PSObject.Properties.Name -contains 'role' -and -not [string]::IsNullOrWhiteSpace([string]$membershipData.role) + + if (-not ($hasState -and $hasRole)) { + $roleVerified = $false + $roleVerificationFailureReason = 'Malformed membership response' + $roleWarning = "GitHub role could not be verified: $roleVerificationFailureReason. Continuing — some controls may report limited visibility." + } else { + $role = [string]$membershipData.role + $roleState = [string]$membershipData.state + $roleVerified = $true + + if ($roleState -eq 'active' -and $role -eq 'admin') { + # No-warning path + } elseif ($roleState -eq 'pending') { + $roleWarning = "GitHub organization membership is pending acceptance. Some controls may report limited visibility until membership is accepted." + } else { + $roleWarning = "GitHub organization admin/owner permissions required for full CIS coverage. Current role: '$role'. Some controls may skip or report limited visibility." + } + } + } catch { + $code = Get-MtGitHubErrorStatusCode -ErrorRecord $_ + $apiMsg = Get-MtGitHubErrorMessage -ErrorRecord $_ + $roleVerified = $false + $roleVerificationFailureReason = "${code}: $apiMsg" + $roleWarning = "GitHub role could not be verified (HTTP $code). $apiMsg Continuing — some controls may report limited visibility." + } + $__MtSession.GitHubAuthHeader = $authHeaders $__MtSession.GitHubConnection = [PSCustomObject]@{ - Connected = $true - Organization = $Organization - ApiBaseUri = $ApiBaseUri - ApiVersion = $ApiVersion - TokenLogin = $user.login - Plan = $planName - FailureReason = $null + Connected = $true + Organization = $Organization + ApiBaseUri = $ApiBaseUri + ApiVersion = $ApiVersion + TokenLogin = $user.login + Plan = $planName + Role = $role + RoleState = $roleState + RoleVerified = $roleVerified + RoleVerificationFailureReason = $roleVerificationFailureReason + FailureReason = $null + } + + if ($roleWarning) { + Write-Warning $roleWarning } $planDisplay = if ($planName) { " (plan: $planName)" } else { '' } diff --git a/powershell/public/core/Disconnect-MtGitHub.ps1 b/powershell/public/core/Disconnect-MtGitHub.ps1 new file mode 100644 index 000000000..2f1743e95 --- /dev/null +++ b/powershell/public/core/Disconnect-MtGitHub.ps1 @@ -0,0 +1,38 @@ +function Disconnect-MtGitHub { + <# + .SYNOPSIS + Clears the current GitHub REST session in Maester. + + .DESCRIPTION + Removes the GitHub PAT-derived auth header, the connection metadata, and the per-session + REST response cache from the Maester module's session state. Idempotent — safe to call when + no GitHub session is active. + + Use this when you want to drop the in-memory token, switch organizations, or clean up + troubleshooting state. Disconnect-Maester also calls this automatically (Disconnect-MtGraph + alias does not, to preserve its narrow Graph-only semantic). + + .EXAMPLE + Disconnect-MtGitHub + + Clears any active GitHub session. + + .LINK + https://maester.dev/docs/commands/Disconnect-MtGitHub + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Consistent with other Connect/Disconnect-* functions')] + [CmdletBinding()] + param() + + $hadState = ($null -ne $__MtSession.GitHubConnection) -or ($null -ne $__MtSession.GitHubAuthHeader) + + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + + if ($hadState) { + Write-Host 'Disconnected from GitHub.' -ForegroundColor Green + } else { + Write-Verbose 'No GitHub session to disconnect.' + } +} diff --git a/powershell/public/core/Get-MtSession.ps1 b/powershell/public/core/Get-MtSession.ps1 index d5d81d5cd..80d841f8c 100644 --- a/powershell/public/core/Get-MtSession.ps1 +++ b/powershell/public/core/Get-MtSession.ps1 @@ -1,4 +1,4 @@ -function Get-MtSession { +function Get-MtSession { <# .SYNOPSIS Gets the current Maester session information which includes the current Graph base uri and other details. @@ -7,6 +7,9 @@ .DESCRIPTION The session information can be used to troubleshoot issues with the Maester module. + For security, the GitHubAuthHeader Authorization value is redacted on output so a copied + session dump cannot leak the GitHub PAT. The live session used by internal callers is unchanged. + .EXAMPLE Get-MtSession @@ -19,5 +22,28 @@ param() Write-Verbose 'Getting the current Maester session information.' - Write-Output $__MtSession + + $sessionCopy = @{} + foreach ($key in $__MtSession.Keys) { + $sessionCopy[$key] = $__MtSession[$key] + } + + $authHeader = $__MtSession['GitHubAuthHeader'] + if ($null -ne $authHeader) { + if ($authHeader -is [System.Collections.IDictionary]) { + $redactedHeader = [ordered]@{} + foreach ($k in $authHeader.Keys) { + if ($k -ieq 'Authorization') { + $redactedHeader[$k] = '' + } else { + $redactedHeader[$k] = $authHeader[$k] + } + } + $sessionCopy['GitHubAuthHeader'] = $redactedHeader + } else { + $sessionCopy['GitHubAuthHeader'] = '' + } + } + + Write-Output $sessionCopy } diff --git a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 index b1c1a6b89..01b325e17 100644 --- a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 +++ b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 @@ -111,16 +111,19 @@ Describe 'Connect-MtGitHub' { Context 'Successful connection' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } - Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"myorg","plan":{"name":"enterprise"}}' } } } It 'Sets Connected = $true and stores GitHubAuthHeader' { - Connect-MtGitHub -Organization 'myorg' + Connect-MtGitHub -Organization 'myorg' 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' @@ -130,15 +133,179 @@ Describe 'Connect-MtGitHub' { } } - It 'Both probes use the configured ApiBaseUri and X-GitHub-Api-Version header' { - Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.myco.ghe.com' -ApiVersion '2024-01-01' + It 'All three probes use the configured ApiBaseUri and X-GitHub-Api-Version header' { + Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.myco.ghe.com' -ApiVersion '2024-01-01' 3>$null - Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 2 -ParameterFilter { + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 3 -ParameterFilter { $Uri -match 'api\.myco\.ghe\.com' -and $Headers['X-GitHub-Api-Version'] -eq '2024-01-01' } } } + Context 'Role probe' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { + [PSCustomObject]@{ Content = '{"login":"myorg"}' } + } + } + + It 'admin + active: no warning, Role=admin, RoleVerified=$true, RoleState=active' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + $warns.Count | Should -Be 0 + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.Role | Should -Be 'admin' + $c.RoleState | Should -Be 'active' + $c.RoleVerified | Should -BeTrue + $c.RoleVerificationFailureReason | Should -BeNullOrEmpty + } + } + + It 'admin + pending: warning mentions pending; RoleState=pending, RoleVerified=$true' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"pending","role":"admin"}' } + } + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + $warns.Count | Should -BeGreaterOrEqual 1 + ($warns -join ' ') | Should -Match 'pending' + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.RoleState | Should -Be 'pending' + $c.RoleVerified | Should -BeTrue + } + } + + It 'member + active: warning matches admin/owner phrasing (not "owner role"); Role=member, RoleVerified=$true' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"member"}' } + } + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + $warns.Count | Should -BeGreaterOrEqual 1 + ($warns -join ' ') | Should -Match 'admin/owner|full CIS coverage' + ($warns -join ' ') | Should -Not -Match 'owner role' + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.Role | Should -Be 'member' + $c.RoleVerified | Should -BeTrue + } + } + + It 'unexpected role string: warning emitted; fields populated as returned' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"billing_manager"}' } + } + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + $warns.Count | Should -BeGreaterOrEqual 1 + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.Role | Should -Be 'billing_manager' + $c.RoleState | Should -Be 'active' + $c.RoleVerified | Should -BeTrue + } + } + + It 'probe HTTP 403: warning, RoleVerified=$false, failure reason includes 403 and api message; Connected=$true' { + $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } + $ex = [System.Exception]::new('Forbidden') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 403 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Insufficient permissions to read membership.' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } + + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + + $warns.Count | Should -BeGreaterOrEqual 1 + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.RoleVerified | Should -BeFalse + $c.RoleVerificationFailureReason | Should -Match '^403:' + $c.RoleVerificationFailureReason | Should -Match 'Insufficient permissions' + } + } + + It 'probe HTTP 404: warning, RoleVerified=$false, failure reason includes 404; Connected=$true' { + $fakeResp = [PSCustomObject]@{ StatusCode = 404; Headers = @{} } + $ex = [System.Exception]::new('Not Found') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 404 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Not Found' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } + + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + + $warns.Count | Should -BeGreaterOrEqual 1 + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.RoleVerified | Should -BeFalse + $c.RoleVerificationFailureReason | Should -Match '^404:' + } + } + + It '200 with malformed JSON body: warning, RoleVerified=$false, reason="Malformed membership response"; Connected=$true' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = 'not-json{' } + } + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + $warns.Count | Should -BeGreaterOrEqual 1 + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.RoleVerified | Should -BeFalse + $c.RoleVerificationFailureReason | Should -Be 'Malformed membership response' + } + } + + It '200 with valid JSON missing role field: same as malformed path; Connected=$true' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active"}' } + } + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + $warns.Count | Should -BeGreaterOrEqual 1 + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.RoleVerified | Should -BeFalse + $c.RoleVerificationFailureReason | Should -Be 'Malformed membership response' + } + } + + It '200 with valid JSON missing state field: same as malformed path; Connected=$true' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"role":"admin"}' } + } + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + $warns.Count | Should -BeGreaterOrEqual 1 + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.RoleVerified | Should -BeFalse + $c.RoleVerificationFailureReason | Should -Be 'Malformed membership response' + } + } + } + Context 'Config fallback: pre-loaded MaesterConfig' { It 'Resolves org from pre-loaded MaesterConfig without calling Get-MtMaesterConfig' { $env:MAESTER_GITHUB_TOKEN = 'valid-token' @@ -150,14 +317,17 @@ Describe 'Connect-MtGitHub' { } } Mock Get-MtMaesterConfig -ModuleName Maester { throw 'Get-MtMaesterConfig must not be called when config is pre-loaded' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } - Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"config-org"}' } } - Connect-MtGitHub + Connect-MtGitHub 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue @@ -181,16 +351,19 @@ Describe 'Connect-MtGitHub' { } } Mock Get-MtMaesterConfig -ModuleName Maester { $fakeConfig } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } - Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/' } { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { [PSCustomObject]@{ Content = '{"login":"lazy-org"}' } } } It 'Lazy-loads config and resolves org when MaesterConfig is null and no -Organization supplied' { - Connect-MtGitHub + Connect-MtGitHub 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue @@ -201,7 +374,7 @@ Describe 'Connect-MtGitHub' { } It 'Lazy-loads config for ApiBaseUri and ApiVersion when -Organization is supplied but others are omitted' { - Connect-MtGitHub -Organization 'myorg' + Connect-MtGitHub -Organization 'myorg' 3>$null InModuleScope Maester { $__MtSession.GitHubConnection.Connected | Should -BeTrue @@ -211,7 +384,7 @@ Describe 'Connect-MtGitHub' { } It 'All three config-backed values (org, ApiBaseUri, ApiVersion) are resolved from lazy-loaded config' { - Connect-MtGitHub + Connect-MtGitHub 3>$null InModuleScope Maester { $conn = $__MtSession.GitHubConnection diff --git a/powershell/tests/functions/Disconnect-Maester.Tests.ps1 b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 new file mode 100644 index 000000000..c2ef84e2a --- /dev/null +++ b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 @@ -0,0 +1,76 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Disconnect-Maester — GitHub cleanup branch' { + BeforeEach { + InModuleScope Maester { + $__MtSession.Connections = @() + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + } + } + + AfterEach { + InModuleScope Maester { + $__MtSession.Connections = @() + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + } + } + + Context 'Default name (Disconnect-Maester)' { + It 'Clears all three GitHub session keys' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer token' } + $__MtSession.GitHubCache = @{ 'foo' = 'bar' } + } + Disconnect-Maester 6>$null + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -BeNullOrEmpty + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + $__MtSession.GitHubCache.Count | Should -Be 0 + } + } + } + + Context 'Disconnect-MtMaester alias' { + It 'Clears GitHub state' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer token' } + } + Disconnect-MtMaester 6>$null + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -BeNullOrEmpty + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + } + + Context 'Disconnect-MtGraph alias' { + It 'Does NOT clear GitHub state (Graph-only semantic)' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer token' } + } + Disconnect-MtGraph 6>$null + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -Not -BeNullOrEmpty + $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' + $__MtSession.GitHubAuthHeader | Should -Not -BeNullOrEmpty + $__MtSession.GitHubAuthHeader['Authorization'] | Should -Be 'Bearer token' + } + } + } + + Context 'When no GitHub state exists' { + It 'Produces no GitHub-related host output' { + $hostOutput = Disconnect-Maester 6>&1 | Out-String + $hostOutput | Should -Not -Match 'Disconnected from GitHub' + } + } +} diff --git a/powershell/tests/functions/Disconnect-MtGitHub.Tests.ps1 b/powershell/tests/functions/Disconnect-MtGitHub.Tests.ps1 new file mode 100644 index 000000000..01a4f4e28 --- /dev/null +++ b/powershell/tests/functions/Disconnect-MtGitHub.Tests.ps1 @@ -0,0 +1,51 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Disconnect-MtGitHub' { + BeforeEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + } + } + + AfterEach { + InModuleScope Maester { + $__MtSession.GitHubConnection = $null + $__MtSession.GitHubAuthHeader = $null + $__MtSession.GitHubCache = @{} + } + } + + Context 'When GitHub state is set' { + It 'Clears GitHubConnection, GitHubAuthHeader, and GitHubCache' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer token' } + $__MtSession.GitHubCache = @{ 'foo' = 'bar' } + } + Disconnect-MtGitHub 6>$null + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -BeNullOrEmpty + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + $__MtSession.GitHubCache.Count | Should -Be 0 + } + } + } + + Context 'When GitHub state is already null' { + It 'Does not throw' { + { Disconnect-MtGitHub 6>$null } | Should -Not -Throw + } + + It 'Leaves session keys null' { + Disconnect-MtGitHub 6>$null + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -BeNullOrEmpty + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + } +} diff --git a/powershell/tests/functions/Get-MtSession.Tests.ps1 b/powershell/tests/functions/Get-MtSession.Tests.ps1 new file mode 100644 index 000000000..3c4c3a065 --- /dev/null +++ b/powershell/tests/functions/Get-MtSession.Tests.ps1 @@ -0,0 +1,143 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../../Maester.psd1" -Force +} + +Describe 'Get-MtSession — GitHubAuthHeader redaction' { + BeforeEach { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = $null + } + } + + AfterEach { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = $null + } + } + + Context 'When GitHubAuthHeader is null' { + It 'Returns null without error' { + $result = Get-MtSession + $result.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + + Context 'When GitHubAuthHeader is a hashtable with Authorization (PascalCase)' { + It 'Redacts Authorization and preserves other headers' { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = @{ + Authorization = 'Bearer ghp_realtoken123' + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + 'User-Agent' = 'Maester-GitHubCis' + } + } + $result = Get-MtSession + $result.GitHubAuthHeader | Should -BeOfType [System.Collections.IDictionary] + $result.GitHubAuthHeader['Authorization'] | Should -Be '' + $result.GitHubAuthHeader['Accept'] | Should -Be 'application/vnd.github+json' + $result.GitHubAuthHeader['X-GitHub-Api-Version'] | Should -Be '2022-11-28' + $result.GitHubAuthHeader['User-Agent'] | Should -Be 'Maester-GitHubCis' + } + } + + Context 'When GitHubAuthHeader has lowercase authorization key' { + It 'Redacts the lowercase key and leaves no token in any value' { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = @{ + authorization = 'Bearer ghp_realtoken123' + Accept = 'application/vnd.github+json' + } + } + $result = Get-MtSession + $result.GitHubAuthHeader['authorization'] | Should -Be '' + foreach ($v in $result.GitHubAuthHeader.Values) { + $v | Should -Not -Be 'Bearer ghp_realtoken123' + } + } + } + + Context 'When GitHubAuthHeader has uppercase AUTHORIZATION key' { + It 'Redacts the uppercase key' { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = @{ + AUTHORIZATION = 'Bearer ghp_realtoken123' + Accept = 'application/vnd.github+json' + } + } + $result = Get-MtSession + $result.GitHubAuthHeader['AUTHORIZATION'] | Should -Be '' + foreach ($v in $result.GitHubAuthHeader.Values) { + $v | Should -Not -Be 'Bearer ghp_realtoken123' + } + } + } + + Context 'When GitHubAuthHeader has BOTH Authorization and authorization' { + It 'Redacts every Authorization-like key' { + InModuleScope Maester { + $h = [ordered]@{} + $h['Authorization'] = 'Bearer ghp_realtoken123' + $h['authorization'] = 'Bearer ghp_realtoken123' + $h['Accept'] = 'application/vnd.github+json' + $__MtSession.GitHubAuthHeader = $h + } + $result = Get-MtSession + $result.GitHubAuthHeader['Authorization'] | Should -Be '' + $result.GitHubAuthHeader['authorization'] | Should -Be '' + foreach ($v in $result.GitHubAuthHeader.Values) { + $v | Should -Not -Be 'Bearer ghp_realtoken123' + } + } + } + + Context 'When GitHubAuthHeader is an OrderedDictionary' { + It 'Redacts Authorization and preserves remaining keys' { + InModuleScope Maester { + $h = [ordered]@{} + $h['Authorization'] = 'Bearer ghp_realtoken123' + $h['Accept'] = 'application/vnd.github+json' + $h['X-GitHub-Api-Version'] = '2022-11-28' + $__MtSession.GitHubAuthHeader = $h + } + $result = Get-MtSession + $result.GitHubAuthHeader | Should -BeOfType [System.Collections.IDictionary] + $result.GitHubAuthHeader['Authorization'] | Should -Be '' + $result.GitHubAuthHeader['Accept'] | Should -Be 'application/vnd.github+json' + $result.GitHubAuthHeader['X-GitHub-Api-Version'] | Should -Be '2022-11-28' + } + } + + Context 'When GitHubAuthHeader is an unsupported non-null shape' { + It 'Replaces the entire value with the redacted sentinel string (fail-closed)' { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = 'Bearer ghp_realtoken123' + } + $result = Get-MtSession + $result.GitHubAuthHeader | Should -Be '' + } + + It 'Replaces a PSCustomObject auth-like blob with the redacted sentinel string' { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = [PSCustomObject]@{ Authorization = 'Bearer ghp_realtoken123' } + } + $result = Get-MtSession + $result.GitHubAuthHeader | Should -Be '' + } + } + + Context 'Live session is not mutated by Get-MtSession' { + It 'Leaves $__MtSession.GitHubAuthHeader.Authorization intact for internal callers' { + InModuleScope Maester { + $__MtSession.GitHubAuthHeader = @{ + Authorization = 'Bearer ghp_realtoken123' + Accept = 'application/vnd.github+json' + } + } + Get-MtSession | Out-Null + InModuleScope Maester { + $__MtSession.GitHubAuthHeader['Authorization'] | Should -Be 'Bearer ghp_realtoken123' + } + } + } +} diff --git a/powershell/tests/functions/Test-MtConnection.Tests.ps1 b/powershell/tests/functions/Test-MtConnection.Tests.ps1 index b1e6c3e1e..9cbc5f974 100644 --- a/powershell/tests/functions/Test-MtConnection.Tests.ps1 +++ b/powershell/tests/functions/Test-MtConnection.Tests.ps1 @@ -1,5 +1,22 @@ BeforeAll { Import-Module "$PSScriptRoot/../../Maester.psd1" -Force + + # Bypass Pester's slow command-resolution path when MS service modules are not loaded + # in the test environment. Track which stubs we created so AfterAll cleans up only + # those — never touch real cmdlets that were already present. + $script:createdStubs = @() + foreach ($cmd in 'Get-AzContext','Invoke-AzRestMethod','Get-MgContext','Get-CsTenant') { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { + New-Item -Path "function:global:$cmd" -Value { } | Out-Null + $script:createdStubs += $cmd + } + } +} + +AfterAll { + foreach ($cmd in $script:createdStubs) { + Remove-Item -Path "function:global:$cmd" -ErrorAction SilentlyContinue + } } Describe 'Test-MtConnection — GitHub service' { From 5eecb13106112dc39f6d65e8aba39e79201a5671 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Fri, 8 May 2026 01:15:28 +0000 Subject: [PATCH 10/24] fix: add UTF-8 BOM to PowerShell files containing non-ASCII characters Satisfies the repo's PSUseBOMForUnicodeEncodedFile analyzer rule (enforced by powershell/tests/pester.ps1) on every .ps1 this branch touches that contains non-ASCII content. Without the BOM, build-validation.yaml fails. BOM was prepended via a byte-preserving rewrite (File.ReadAllBytes + Buffer.BlockCopy + File.WriteAllBytes) so file content after the BOM is byte-identical to the prior version. Co-Authored-By: Claude Opus 4.7 --- powershell/public/core/Disconnect-MtGitHub.ps1 | 2 +- powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 | 2 +- powershell/tests/functions/Disconnect-Maester.Tests.ps1 | 2 +- powershell/tests/functions/Get-MtSession.Tests.ps1 | 2 +- powershell/tests/functions/Test-MtConnection.Tests.ps1 | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/powershell/public/core/Disconnect-MtGitHub.ps1 b/powershell/public/core/Disconnect-MtGitHub.ps1 index 2f1743e95..a641eca94 100644 --- a/powershell/public/core/Disconnect-MtGitHub.ps1 +++ b/powershell/public/core/Disconnect-MtGitHub.ps1 @@ -1,4 +1,4 @@ -function Disconnect-MtGitHub { +function Disconnect-MtGitHub { <# .SYNOPSIS Clears the current GitHub REST session in Maester. diff --git a/powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 b/powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 index f43680c4e..23afa4bb0 100644 --- a/powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 +++ b/powershell/tests/functions/Clear-ModuleVariable.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeAll { +BeforeAll { Import-Module "$PSScriptRoot/../../Maester.psd1" -Force } diff --git a/powershell/tests/functions/Disconnect-Maester.Tests.ps1 b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 index c2ef84e2a..60be5896e 100644 --- a/powershell/tests/functions/Disconnect-Maester.Tests.ps1 +++ b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeAll { +BeforeAll { Import-Module "$PSScriptRoot/../../Maester.psd1" -Force } diff --git a/powershell/tests/functions/Get-MtSession.Tests.ps1 b/powershell/tests/functions/Get-MtSession.Tests.ps1 index 3c4c3a065..4205b3e4f 100644 --- a/powershell/tests/functions/Get-MtSession.Tests.ps1 +++ b/powershell/tests/functions/Get-MtSession.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeAll { +BeforeAll { Import-Module "$PSScriptRoot/../../Maester.psd1" -Force } diff --git a/powershell/tests/functions/Test-MtConnection.Tests.ps1 b/powershell/tests/functions/Test-MtConnection.Tests.ps1 index 9cbc5f974..58208f360 100644 --- a/powershell/tests/functions/Test-MtConnection.Tests.ps1 +++ b/powershell/tests/functions/Test-MtConnection.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeAll { +BeforeAll { Import-Module "$PSScriptRoot/../../Maester.psd1" -Force # Bypass Pester's slow command-resolution path when MS service modules are not loaded From 51fc0002a90b337baeefea6270312afbd364d697 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Fri, 8 May 2026 01:42:31 +0000 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20address=20GitHub=20review=20findin?= =?UTF-8?q?gs=20=E2=80=94=20org=20access,=20https=20check,=20disconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect-MtGitHub: promote /orgs/{org}/memberships/{user} from advisory to blocking. /orgs/{org} returns public metadata even for tokens with no relationship to the org, so it is not a real access proof. 4xx, malformed body, or missing state/role on the membership probe now sets FailureReason = 'OrgMembershipFailed' and aborts before storing auth headers; valid non-admin / pending states still connect with a warning. Connect-MtGitHub: revalidate the resolved ApiBaseUri after the param→config→default fallback. [ValidatePattern('^https://')] only fires on the bound parameter, so a config-sourced http:// URI could otherwise reach Invoke-WebRequest with a Bearer header. Resolution now uses a local variable, then enforces an absolute https:// URI via [uri]::TryCreate + case-sensitive scheme check, failing with FailureReason = 'InvalidApiBaseUri'. Disconnect-Maester: normalize $MyInvocation.InvocationName by splitting on the literal '\\' qualifier (PowerShell uses '\\' on all OSes; Split-Path -Leaf would no-op on Linux). 'Maester\\Disconnect-Maester' now routes to the GitHub-clearing branch alongside the bare and Disconnect-MtMaester forms; Disconnect-MtGraph keeps its narrow Graph-only semantic. Tests: move the five membership-failure cases into a new OrgMembershipFailed context with Connected=$false assertions; add an InvalidApiBaseUri context covering http:// config, non-URI config, and trailing-slash trim on a valid parameter; add a Maester\\Disconnect-Maester clearing test. Failure-path tests also assert GitHubAuthHeader stays \$null to lock in the security property that no Bearer header is left behind on a failed connection. Co-Authored-By: Claude Opus 4.7 --- powershell/public/Disconnect-Maester.ps1 | 9 +- powershell/public/core/Connect-MtGitHub.ps1 | 98 +++++++----- .../functions/Connect-MtGitHub.Tests.ps1 | 149 +++++++++++++----- .../functions/Disconnect-Maester.Tests.ps1 | 16 ++ 4 files changed, 194 insertions(+), 78 deletions(-) diff --git a/powershell/public/Disconnect-Maester.ps1 b/powershell/public/Disconnect-Maester.ps1 index 09cbd6473..74667ded2 100644 --- a/powershell/public/Disconnect-Maester.ps1 +++ b/powershell/public/Disconnect-Maester.ps1 @@ -54,7 +54,14 @@ Disconnect-MicrosoftTeams } - $invokedAs = $MyInvocation.InvocationName + # Strip module qualifier (e.g. 'Maester\Disconnect-Maester') so module-qualified + # invocation still routes to the GitHub-clearing branch. PowerShell uses '\' for + # the qualifier on all OSes, so split on the literal char rather than Split-Path. + $invokedAs = if ([string]::IsNullOrEmpty($MyInvocation.InvocationName)) { + $MyInvocation.MyCommand.Name + } else { + ($MyInvocation.InvocationName -split '\\')[-1] + } if ($invokedAs -iin @('Disconnect-Maester','Disconnect-MtMaester')) { Disconnect-MtGitHub } diff --git a/powershell/public/core/Connect-MtGitHub.ps1 b/powershell/public/core/Connect-MtGitHub.ps1 index 2a409766d..539f8bbc7 100644 --- a/powershell/public/core/Connect-MtGitHub.ps1 +++ b/powershell/public/core/Connect-MtGitHub.ps1 @@ -5,16 +5,18 @@ .DESCRIPTION Establishes a GitHub REST API session for Maester CIS GitHub Enterprise Cloud benchmark - tests. Validates the PAT via three probes: + tests. Validates the PAT via three probes, all of which must succeed: 1. GET /user — token identity - 2. GET /orgs/{org} — org access - 3. GET /orgs/{org}/memberships/{user} — role check (admin vs member, active vs pending) + 2. GET /orgs/{org} — org metadata (login, plan) + 3. GET /orgs/{org}/memberships/{user} — org-access proof (state + role) - The third probe populates Role, RoleState, RoleVerified, and RoleVerificationFailureReason - on the connection object. RoleVerified = $true with Role = 'admin' and RoleState = 'active' - is the no-warning path; anything else (member role, pending state, or a probe failure) - emits a warning. A failed role probe does NOT block connection — token validity and org - access are already proven. + The membership probe is the real access gate: /orgs/{org} returns public metadata + even for tokens with no relationship to the organization. A 4xx, malformed body, + or missing state/role on probe 3 aborts with FailureReason = 'OrgMembershipFailed'. + + On success, Role = 'admin' + RoleState = 'active' is the no-warning path. Other + valid roles (member, billing_manager, etc.) or 'pending' state still connect but + emit a warning indicating limited CIS coverage. Token resolution order: 1. -Token parameter (SecureString) @@ -97,14 +99,26 @@ return } - # Resolve ApiBaseUri (param -> config -> default) - # Use a local variable for the config lookup to avoid triggering [ValidatePattern] on $null. - if ([string]::IsNullOrWhiteSpace($ApiBaseUri)) { + # Resolve ApiBaseUri (param -> config -> default) into a local variable. Reassigning + # $ApiBaseUri would re-trigger [ValidatePattern] and short-circuit the config path with + # a hard exception instead of our InvalidApiBaseUri failure. + $resolvedApiBaseUri = $ApiBaseUri + if ([string]::IsNullOrWhiteSpace($resolvedApiBaseUri)) { $configApiBaseUri = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiBaseUri' - if (-not [string]::IsNullOrWhiteSpace($configApiBaseUri)) { $ApiBaseUri = $configApiBaseUri } + if (-not [string]::IsNullOrWhiteSpace($configApiBaseUri)) { $resolvedApiBaseUri = $configApiBaseUri } } - if ([string]::IsNullOrWhiteSpace($ApiBaseUri)) { $ApiBaseUri = 'https://api.github.com' } - $ApiBaseUri = $ApiBaseUri.TrimEnd('/') + if ([string]::IsNullOrWhiteSpace($resolvedApiBaseUri)) { $resolvedApiBaseUri = 'https://api.github.com' } + $resolvedApiBaseUri = $resolvedApiBaseUri.TrimEnd('/') + + # Revalidate the fully-resolved URI. [ValidatePattern] on the param only fires when -ApiBaseUri + # is bound, so a config value can otherwise reach Invoke-WebRequest with a Bearer header on http://. + $parsedUri = $null + if (-not [uri]::TryCreate($resolvedApiBaseUri, [UriKind]::Absolute, [ref]$parsedUri) -or $parsedUri.Scheme -cne 'https') { + Write-Host "`nGitHub API base URI must be an absolute https:// URI. Got: '$resolvedApiBaseUri'." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'InvalidApiBaseUri' } + return + } + $ApiBaseUri = $resolvedApiBaseUri # Resolve ApiVersion (param -> config -> default) # Use a local variable for the config lookup to avoid triggering [ValidatePattern] on $null. @@ -180,14 +194,12 @@ $planName = $orgData.plan.name } - # Probe 3: org membership / role - # Distinguishes "known non-admin" (RoleVerified=$true, Role!='admin') from - # "could not check" (RoleVerified=$false). Failures here never block connection. - $role = $null - $roleState = $null - $roleVerified = $false - $roleVerificationFailureReason = $null - $roleWarning = $null + # Probe 3: org membership / role — blocking proof of org access. + # /orgs/{org} returns public org metadata even for tokens with no relationship to the org, + # so membership is the real access gate. Any failure here aborts the connection. + $role = $null + $roleState = $null + $roleWarning = $null $encodedLogin = [System.Uri]::EscapeDataString($user.login) try { @@ -204,28 +216,32 @@ $hasRole = $null -ne $membershipData -and $membershipData.PSObject.Properties.Name -contains 'role' -and -not [string]::IsNullOrWhiteSpace([string]$membershipData.role) if (-not ($hasState -and $hasRole)) { - $roleVerified = $false - $roleVerificationFailureReason = 'Malformed membership response' - $roleWarning = "GitHub role could not be verified: $roleVerificationFailureReason. Continuing — some controls may report limited visibility." + Write-Host "`nGitHub organization membership could not be verified: malformed response from /orgs/$Organization/memberships/$($user.login)." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'OrgMembershipFailed' } + return + } + + $role = [string]$membershipData.role + $roleState = [string]$membershipData.state + + if ($roleState -eq 'active' -and $role -eq 'admin') { + # No-warning path + } elseif ($roleState -eq 'pending') { + $roleWarning = "GitHub organization membership is pending acceptance. Some controls may report limited visibility until membership is accepted." } else { - $role = [string]$membershipData.role - $roleState = [string]$membershipData.state - $roleVerified = $true - - if ($roleState -eq 'active' -and $role -eq 'admin') { - # No-warning path - } elseif ($roleState -eq 'pending') { - $roleWarning = "GitHub organization membership is pending acceptance. Some controls may report limited visibility until membership is accepted." - } else { - $roleWarning = "GitHub organization admin/owner permissions required for full CIS coverage. Current role: '$role'. Some controls may skip or report limited visibility." - } + $roleWarning = "GitHub organization admin/owner permissions required for full CIS coverage. Current role: '$role'. Some controls may skip or report limited visibility." } } catch { $code = Get-MtGitHubErrorStatusCode -ErrorRecord $_ $apiMsg = Get-MtGitHubErrorMessage -ErrorRecord $_ - $roleVerified = $false - $roleVerificationFailureReason = "${code}: $apiMsg" - $roleWarning = "GitHub role could not be verified (HTTP $code). $apiMsg Continuing — some controls may report limited visibility." + $msg = switch ($code) { + 403 { "Membership probe forbidden (403). The token cannot prove membership in '$Organization'. GitHub API: $apiMsg" } + 404 { "User '$($user.login)' is not a member of organization '$Organization' (404). GitHub API: $apiMsg" } + default { "Membership probe failed (HTTP $code). $apiMsg" } + } + Write-Host "`nFailed to verify GitHub organization membership: $msg" -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'OrgMembershipFailed' } + return } $__MtSession.GitHubAuthHeader = $authHeaders @@ -238,8 +254,8 @@ Plan = $planName Role = $role RoleState = $roleState - RoleVerified = $roleVerified - RoleVerificationFailureReason = $roleVerificationFailureReason + RoleVerified = $true + RoleVerificationFailureReason = $null FailureReason = $null } diff --git a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 index 01b325e17..65fa27aa7 100644 --- a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 +++ b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 @@ -219,7 +219,20 @@ Describe 'Connect-MtGitHub' { } } - It 'probe HTTP 403: warning, RoleVerified=$false, failure reason includes 403 and api message; Connected=$true' { + } + + Context 'Failure: OrgMembershipFailed' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { + [PSCustomObject]@{ Content = '{"login":"myorg"}' } + } + } + + It 'Membership HTTP 403 fails connection with FailureReason = OrgMembershipFailed' { $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp @@ -227,20 +240,18 @@ Describe 'Connect-MtGitHub' { Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Insufficient permissions to read membership.' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } - $warns = @() - Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + Connect-MtGitHub -Organization 'myorg' 6>$null - $warns.Count | Should -BeGreaterOrEqual 1 InModuleScope Maester { $c = $__MtSession.GitHubConnection - $c.Connected | Should -BeTrue - $c.RoleVerified | Should -BeFalse - $c.RoleVerificationFailureReason | Should -Match '^403:' - $c.RoleVerificationFailureReason | Should -Match 'Insufficient permissions' + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'OrgMembershipFailed' + # Security property: failed connection must not leave a Bearer header in session. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } - It 'probe HTTP 404: warning, RoleVerified=$false, failure reason includes 404; Connected=$true' { + It 'Membership HTTP 404 fails connection with FailureReason = OrgMembershipFailed' { $fakeResp = [PSCustomObject]@{ StatusCode = 404; Headers = @{} } $ex = [System.Exception]::new('Not Found') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp @@ -248,60 +259,126 @@ Describe 'Connect-MtGitHub' { Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Not Found' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { throw $ex } - $warns = @() - Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + Connect-MtGitHub -Organization 'myorg' 6>$null - $warns.Count | Should -BeGreaterOrEqual 1 InModuleScope Maester { $c = $__MtSession.GitHubConnection - $c.Connected | Should -BeTrue - $c.RoleVerified | Should -BeFalse - $c.RoleVerificationFailureReason | Should -Match '^404:' + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'OrgMembershipFailed' + # Security property: failed connection must not leave a Bearer header in session. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } - It '200 with malformed JSON body: warning, RoleVerified=$false, reason="Malformed membership response"; Connected=$true' { + It '200 with malformed JSON body fails connection with FailureReason = OrgMembershipFailed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = 'not-json{' } } - $warns = @() - Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null - $warns.Count | Should -BeGreaterOrEqual 1 + Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection - $c.Connected | Should -BeTrue - $c.RoleVerified | Should -BeFalse - $c.RoleVerificationFailureReason | Should -Be 'Malformed membership response' + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'OrgMembershipFailed' + # Security property: failed connection must not leave a Bearer header in session. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } - It '200 with valid JSON missing role field: same as malformed path; Connected=$true' { + It '200 with valid JSON missing role field fails connection with FailureReason = OrgMembershipFailed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active"}' } } - $warns = @() - Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null - $warns.Count | Should -BeGreaterOrEqual 1 + Connect-MtGitHub -Organization 'myorg' 6>$null InModuleScope Maester { $c = $__MtSession.GitHubConnection - $c.Connected | Should -BeTrue - $c.RoleVerified | Should -BeFalse - $c.RoleVerificationFailureReason | Should -Be 'Malformed membership response' + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'OrgMembershipFailed' + # Security property: failed connection must not leave a Bearer header in session. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty } } - It '200 with valid JSON missing state field: same as malformed path; Connected=$true' { + It '200 with valid JSON missing state field fails connection with FailureReason = OrgMembershipFailed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"role":"admin"}' } } - $warns = @() - Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null - $warns.Count | Should -BeGreaterOrEqual 1 + Connect-MtGitHub -Organization 'myorg' 6>$null + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'OrgMembershipFailed' + # Security property: failed connection must not leave a Bearer header in session. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + } + + Context 'Failure: InvalidApiBaseUri' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + } + + It 'Config http:// URI fails before any web request' { + InModuleScope Maester { + $__MtSession.MaesterConfig = [PSCustomObject]@{ + GlobalSettings = [PSCustomObject]@{ + GitHubApiBaseUri = 'http://api.example.com' + } + } + } + Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } + + Connect-MtGitHub -Organization 'myorg' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiBaseUri' + # Security property: invalid URI must short-circuit before headers are stored. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + } + + It 'Config non-URI value fails before any web request' { + InModuleScope Maester { + $__MtSession.MaesterConfig = [PSCustomObject]@{ + GlobalSettings = [PSCustomObject]@{ + GitHubApiBaseUri = 'not-a-uri' + } + } + } + Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } + + Connect-MtGitHub -Organization 'myorg' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiBaseUri' + # Security property: invalid URI must short-circuit before headers are stored. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + } + + It 'Parameter https URI with trailing slash still works and is trimmed' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { + [PSCustomObject]@{ Content = '{"login":"myorg"}' } + } + + Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.example.com/' 3>$null + InModuleScope Maester { $c = $__MtSession.GitHubConnection - $c.Connected | Should -BeTrue - $c.RoleVerified | Should -BeFalse - $c.RoleVerificationFailureReason | Should -Be 'Malformed membership response' + $c.Connected | Should -BeTrue + $c.ApiBaseUri | Should -Be 'https://api.example.com' } } } diff --git a/powershell/tests/functions/Disconnect-Maester.Tests.ps1 b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 index 60be5896e..eb755a51e 100644 --- a/powershell/tests/functions/Disconnect-Maester.Tests.ps1 +++ b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 @@ -37,6 +37,22 @@ Describe 'Disconnect-Maester — GitHub cleanup branch' { } } + Context 'Module-qualified invocation (Maester\Disconnect-Maester)' { + It 'Clears all three GitHub session keys' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer token' } + $__MtSession.GitHubCache = @{ 'foo' = 'bar' } + } + Maester\Disconnect-Maester 6>$null + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -BeNullOrEmpty + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + $__MtSession.GitHubCache.Count | Should -Be 0 + } + } + } + Context 'Disconnect-MtMaester alias' { It 'Clears GitHub state' { InModuleScope Maester { From d79936319ae6270bf021e18c86e565b1e92bd387 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Fri, 8 May 2026 02:19:24 +0000 Subject: [PATCH 12/24] feat: add admin permission probe and ApiVersion validation to Connect-MtGitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth non-blocking probe to GET /orgs/{org}/actions/permissions to verify the token can reach an org-administration endpoint. Failure records AdministrationPermissionVerified = $false and warns about both classic (admin:org) and fine-grained (Administration: read) permission models, but does not flip Connected — the session remains usable for controls that do not require org administration access. Captures the x-accepted-github-permissions response header on failures. Validates the resolved ApiVersion locally so a malformed config value no longer reaches /user as X-GitHub-Api-Version and gets misreported as TokenInvalid. Removes the parameter [ValidatePattern] so invalid -ApiVersion input also flows through the resolved-value check, which ensures session-clearing logic runs first. /user catch now maps 410 and 400 with API/version wording (including GitHub's documented "Not a supported version" message) to InvalidApiVersion instead of TokenInvalid. Updates synopsis to describe the session as organization-scoped (not enterprise-admin) and lists required token permissions for both PAT types. Co-Authored-By: Claude Opus 4.7 --- powershell/public/core/Connect-MtGitHub.ps1 | 139 +++++++--- .../functions/Connect-MtGitHub.Tests.ps1 | 253 +++++++++++++++++- 2 files changed, 351 insertions(+), 41 deletions(-) diff --git a/powershell/public/core/Connect-MtGitHub.ps1 b/powershell/public/core/Connect-MtGitHub.ps1 index 539f8bbc7..899c1edf3 100644 --- a/powershell/public/core/Connect-MtGitHub.ps1 +++ b/powershell/public/core/Connect-MtGitHub.ps1 @@ -1,32 +1,48 @@ function Connect-MtGitHub { <# .SYNOPSIS - Connects to the GitHub REST API for Maester security testing. + Establishes a GitHub Enterprise Cloud organization REST API session for Maester. .DESCRIPTION - Establishes a GitHub REST API session for Maester CIS GitHub Enterprise Cloud benchmark - tests. Validates the PAT via three probes, all of which must succeed: - 1. GET /user — token identity - 2. GET /orgs/{org} — org metadata (login, plan) - 3. GET /orgs/{org}/memberships/{user} — org-access proof (state + role) + Establishes a GitHub Enterprise Cloud organization REST API session for Maester CIS + benchmark tests. This is an organization-scoped session, not a full enterprise-admin + session: enterprise-admin endpoints under /enterprises/{enterprise} are not verified + by this command and would require a future enterprise-access probe if CIS controls + need them. + + Validates the PAT via four probes. The first three are blocking (any failure aborts + the connection); the fourth is non-blocking (failure emits a warning but the session + is still established): + 1. GET /user — token identity (blocking) + 2. GET /orgs/{org} — org metadata (blocking) + 3. GET /orgs/{org}/memberships/{user} — org-access proof, state + role (blocking) + 4. GET /orgs/{org}/actions/permissions — administration access probe (non-blocking) The membership probe is the real access gate: /orgs/{org} returns public metadata even for tokens with no relationship to the organization. A 4xx, malformed body, or missing state/role on probe 3 aborts with FailureReason = 'OrgMembershipFailed'. - On success, Role = 'admin' + RoleState = 'active' is the no-warning path. Other - valid roles (member, billing_manager, etc.) or 'pending' state still connect but - emit a warning indicating limited CIS coverage. + Probe 4 verifies the token can reach an org-administration endpoint. GitHub + documents /orgs/{org}/actions/permissions as requiring classic PAT 'admin:org' or + fine-grained 'Organization Administration: read'. Failure here records + AdministrationPermissionVerified = $false and emits a warning, but does not flip + Connected to $false — the session remains usable for controls that don't require + org administration access. + + On success, Role = 'admin' + RoleState = 'active' + AdministrationPermissionVerified + = $true is the no-warning path. Other valid roles (member, billing_manager, etc.) + or 'pending' state, or admin-probe failure, still connect but emit warnings + indicating limited CIS coverage. Token resolution order: 1. -Token parameter (SecureString) 2. MAESTER_GITHUB_TOKEN environment variable 3. GH_TOKEN environment variable (GitHub CLI convention) - Required permissions (classic PAT): admin:org - Fine-grained PAT (expected; validate in integration testing): - Organization Administration: read + Organization Members: read - Required GitHub role: organization owner for full org settings visibility. + Required permissions: + Classic PAT: admin:org + Fine-grained PAT: Organization Members: read + Organization Administration: read + Required role for full coverage: organization owner/admin Note: Connection success proves token validity and org access, not that all CIS control fields will be visible. Each CIS test validates field availability @@ -69,7 +85,6 @@ [string] $ApiBaseUri, [Parameter(Mandatory = $false)] - [ValidatePattern('^\d{4}-\d{2}-\d{2}$')] [string] $ApiVersion ) @@ -120,13 +135,23 @@ } $ApiBaseUri = $resolvedApiBaseUri - # Resolve ApiVersion (param -> config -> default) - # Use a local variable for the config lookup to avoid triggering [ValidatePattern] on $null. - if ([string]::IsNullOrWhiteSpace($ApiVersion)) { + # Resolve ApiVersion (param -> config -> default). Validation runs on the resolved value + # so the same FailureReason path applies whether the bad value came from -ApiVersion or + # GitHubApiVersion config — a parameter [ValidatePattern] would otherwise throw before + # the session-clearing logic at the top of this function ran. + $resolvedApiVersion = $ApiVersion + if ([string]::IsNullOrWhiteSpace($resolvedApiVersion)) { $configApiVersion = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiVersion' - if (-not [string]::IsNullOrWhiteSpace($configApiVersion)) { $ApiVersion = $configApiVersion } + if (-not [string]::IsNullOrWhiteSpace($configApiVersion)) { $resolvedApiVersion = $configApiVersion } } - if ([string]::IsNullOrWhiteSpace($ApiVersion)) { $ApiVersion = '2022-11-28' } + if ([string]::IsNullOrWhiteSpace($resolvedApiVersion)) { $resolvedApiVersion = '2022-11-28' } + + if ($resolvedApiVersion -notmatch '^\d{4}-\d{2}-\d{2}$') { + Write-Host "`nGitHub API version must use YYYY-MM-DD format. Got: '$resolvedApiVersion'." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'InvalidApiVersion' } + return + } + $ApiVersion = $resolvedApiVersion # Resolve token (param -> MAESTER_GITHUB_TOKEN -> GH_TOKEN) $plainToken = $null @@ -164,7 +189,17 @@ $user = $userResponse.Content | ConvertFrom-Json Write-Verbose "GitHub token identity: $($user.login)" } catch { - $code = Get-MtGitHubErrorStatusCode -ErrorRecord $_ + $code = Get-MtGitHubErrorStatusCode -ErrorRecord $_ + $apiMsg = Get-MtGitHubErrorMessage -ErrorRecord $_ + # 410 = unsupported API version. 400 with a message about API/version support + # also indicates an unsupported X-GitHub-Api-Version header value — GitHub's + # documented wording includes "Not a supported version" and "version is not supported". + $isUnsupportedApiVersion = $code -eq 410 -or ($code -eq 400 -and $apiMsg -match '(?i)api\s+version|x-github-api-version|not\s+.*supported.*version|version.*not\s+.*supported') + if ($isUnsupportedApiVersion) { + Write-Host "`nGitHub API version '$ApiVersion' is not supported by GitHub. Update GitHubApiVersion or omit it to use the default." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'InvalidApiVersion' } + return + } Write-Host "`nGitHub token validation failed (HTTP $code). Verify the PAT is valid and not expired." -ForegroundColor Red $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'TokenInvalid' } return @@ -244,24 +279,60 @@ return } + # Probe 4: organization administration access (non-blocking). + # /orgs/{org}/actions/permissions requires classic PAT 'admin:org' or fine-grained + # 'Organization Administration: read' — a closer match to the permissions needed by + # CIS org-admin controls than the membership endpoint. Failure here records the + # outcome and emits a warning, but does not flip Connected to $false. + $adminVerified = $false + $adminFailureReason = $null + $adminStatusCode = $null + $adminAcceptedPermissions = $null + $adminWarning = $null + try { + $adminResponse = Invoke-WebRequest -Uri "$ApiBaseUri/orgs/$encodedOrg/actions/permissions" -Headers $authHeaders -Method GET -UseBasicParsing -ErrorAction Stop + $adminVerified = $true + $adminStatusCode = 200 + if ($adminResponse.PSObject.Properties.Name -contains 'StatusCode' -and $null -ne $adminResponse.StatusCode) { + $adminStatusCode = [int]$adminResponse.StatusCode + } + } catch { + $adminStatusCode = Get-MtGitHubErrorStatusCode -ErrorRecord $_ + $adminApiMsg = Get-MtGitHubErrorMessage -ErrorRecord $_ + $respHeaders = $null + if ($null -ne $_.Exception -and $null -ne $_.Exception.Response) { + $respHeaders = $_.Exception.Response.Headers + } + $adminAcceptedPermissions = Get-MtGitHubResponseHeaderValue -Headers $respHeaders -Name 'x-accepted-github-permissions' + $adminFailureReason = switch ($adminStatusCode) { + 403 { "HTTP 403 from /orgs/$Organization/actions/permissions. $adminApiMsg" } + 404 { "HTTP 404 from /orgs/$Organization/actions/permissions. $adminApiMsg" } + default { "HTTP $adminStatusCode from /orgs/$Organization/actions/permissions. $adminApiMsg" } + } + $adminWarning = "GitHub organization administration API access was not verified. Some CIS controls requiring org administration may skip or report limited visibility. Required token permissions — classic PAT: admin:org; fine-grained PAT: Organization Administration: read. Detail: $adminFailureReason" + } + $__MtSession.GitHubAuthHeader = $authHeaders $__MtSession.GitHubConnection = [PSCustomObject]@{ - Connected = $true - Organization = $Organization - ApiBaseUri = $ApiBaseUri - ApiVersion = $ApiVersion - TokenLogin = $user.login - Plan = $planName - Role = $role - RoleState = $roleState - RoleVerified = $true - RoleVerificationFailureReason = $null - FailureReason = $null + Connected = $true + Organization = $Organization + ApiBaseUri = $ApiBaseUri + ApiVersion = $ApiVersion + TokenLogin = $user.login + Plan = $planName + Role = $role + RoleState = $roleState + RoleVerified = $true + RoleVerificationFailureReason = $null + AdministrationPermissionVerified = $adminVerified + AdministrationPermissionFailureReason = $adminFailureReason + AdministrationPermissionStatusCode = $adminStatusCode + AdministrationPermissionAcceptedPermissions = $adminAcceptedPermissions + FailureReason = $null } - if ($roleWarning) { - Write-Warning $roleWarning - } + if ($roleWarning) { Write-Warning $roleWarning } + if ($adminWarning) { Write-Warning $adminWarning } $planDisplay = if ($planName) { " (plan: $planName)" } else { '' } Write-Host "Connected to GitHub organization '$($orgData.login)' as '$($user.login)'$planDisplay." -ForegroundColor Green diff --git a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 index 65fa27aa7..b34b3d97a 100644 --- a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 +++ b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 @@ -111,6 +111,9 @@ Describe 'Connect-MtGitHub' { Context 'Successful connection' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { + [PSCustomObject]@{ Content = '{"enabled_repositories":"all"}'; StatusCode = 200 } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } @@ -125,26 +128,47 @@ Describe 'Connect-MtGitHub' { It 'Sets Connected = $true and stores GitHubAuthHeader' { Connect-MtGitHub -Organization 'myorg' 3>$null InModuleScope Maester { - $__MtSession.GitHubConnection.Connected | Should -BeTrue - $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' - $__MtSession.GitHubConnection.TokenLogin | Should -Be 'testuser' - $__MtSession.GitHubAuthHeader | Should -Not -BeNullOrEmpty - $__MtSession.GitHubAuthHeader['Authorization'] | Should -Match '^Bearer ' + $__MtSession.GitHubConnection.Connected | Should -BeTrue + $__MtSession.GitHubConnection.Organization | Should -Be 'myorg' + $__MtSession.GitHubConnection.TokenLogin | Should -Be 'testuser' + $__MtSession.GitHubConnection.AdministrationPermissionVerified | Should -BeTrue + $__MtSession.GitHubAuthHeader | Should -Not -BeNullOrEmpty + $__MtSession.GitHubAuthHeader['Authorization'] | Should -Match '^Bearer ' } } - It 'All three probes use the configured ApiBaseUri and X-GitHub-Api-Version header' { + It 'All four probes use the configured ApiBaseUri and X-GitHub-Api-Version header' { Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.myco.ghe.com' -ApiVersion '2024-01-01' 3>$null - Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 3 -ParameterFilter { + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 4 -ParameterFilter { $Uri -match 'api\.myco\.ghe\.com' -and $Headers['X-GitHub-Api-Version'] -eq '2024-01-01' } } + + It 'GHE.com data residency: all four endpoint paths target the configured base URI' { + Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'https://api.octocorp.ghe.com' 3>$null + + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { + $Uri -eq 'https://api.octocorp.ghe.com/user' + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { + $Uri -eq 'https://api.octocorp.ghe.com/orgs/myorg' + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { + $Uri -eq 'https://api.octocorp.ghe.com/orgs/myorg/memberships/testuser' + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 1 -ParameterFilter { + $Uri -eq 'https://api.octocorp.ghe.com/orgs/myorg/actions/permissions' + } + } } Context 'Role probe' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { + [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { [PSCustomObject]@{ Content = '{"login":"testuser"}' } } @@ -167,6 +191,7 @@ Describe 'Connect-MtGitHub' { $c.RoleState | Should -Be 'active' $c.RoleVerified | Should -BeTrue $c.RoleVerificationFailureReason | Should -BeNullOrEmpty + $c.AdministrationPermissionVerified | Should -BeTrue } } @@ -221,6 +246,98 @@ Describe 'Connect-MtGitHub' { } + Context 'Administration permission probe' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { + [PSCustomObject]@{ Content = '{"login":"myorg"}' } + } + } + + It 'admin + active + admin probe 200: Connected=true, AdministrationPermissionVerified=true, no admin warning, four IWR calls' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { + [PSCustomObject]@{ Content = '{"enabled_repositories":"all"}'; StatusCode = 200 } + } + + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + + ($warns -join ' ') | Should -Not -Match 'administration API access' + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.AdministrationPermissionVerified | Should -BeTrue + $c.AdministrationPermissionFailureReason | Should -BeNullOrEmpty + $c.AdministrationPermissionStatusCode | Should -Be 200 + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 4 + } + + It 'admin + active + admin probe 403: Connected=true, FailureReason=null, AdministrationPermissionVerified=false, warning mentions both permission models' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } + $fakeResp = [PSCustomObject]@{ + StatusCode = 403 + Headers = @{ 'x-accepted-github-permissions' = 'administration=read' } + } + $ex = [System.Exception]::new('Forbidden') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 403 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Resource not accessible by personal access token' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { throw $ex } + + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + + $combined = $warns -join ' ' + $combined | Should -Match 'admin:org' + $combined | Should -Match 'Administration: read' + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.FailureReason | Should -BeNullOrEmpty + $c.Role | Should -Be 'admin' + $c.AdministrationPermissionVerified | Should -BeFalse + $c.AdministrationPermissionStatusCode | Should -Be 403 + $c.AdministrationPermissionFailureReason | Should -Match 'HTTP 403' + $c.AdministrationPermissionAcceptedPermissions | Should -Be 'administration=read' + } + } + + It 'member + active + admin probe 403: emits both role and admin-permission warnings' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"member"}' } + } + $fakeResp = [PSCustomObject]@{ StatusCode = 403; Headers = @{} } + $ex = [System.Exception]::new('Forbidden') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 403 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Resource not accessible' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { throw $ex } + + $warns = @() + Connect-MtGitHub -Organization 'myorg' -WarningAction SilentlyContinue -WarningVariable warns 3>$null + + $warns.Count | Should -BeGreaterOrEqual 2 + $combined = $warns -join ' ' + $combined | Should -Match 'admin/owner|full CIS coverage' + $combined | Should -Match 'administration API access|Administration: read' + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.Role | Should -Be 'member' + $c.AdministrationPermissionVerified | Should -BeFalse + } + } + } + Context 'Failure: OrgMembershipFailed' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' @@ -363,6 +480,9 @@ Describe 'Connect-MtGitHub' { } It 'Parameter https URI with trailing slash still works and is trimmed' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { + [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } @@ -394,6 +514,9 @@ Describe 'Connect-MtGitHub' { } } Mock Get-MtMaesterConfig -ModuleName Maester { throw 'Get-MtMaesterConfig must not be called when config is pre-loaded' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { + [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } @@ -428,6 +551,9 @@ Describe 'Connect-MtGitHub' { } } Mock Get-MtMaesterConfig -ModuleName Maester { $fakeConfig } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { + [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } + } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } } @@ -471,4 +597,117 @@ Describe 'Connect-MtGitHub' { } } } + + Context 'Failure: InvalidApiVersion' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + } + + It 'Config GitHubApiVersion = "latest" fails before any web request' { + InModuleScope Maester { + $__MtSession.MaesterConfig = [PSCustomObject]@{ + GlobalSettings = [PSCustomObject]@{ + GitHubApiVersion = 'latest' + } + } + } + Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiVersion is invalid' } + + Connect-MtGitHub -Organization 'myorg' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiVersion' + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + } + + It 'Config GitHubApiVersion with valid format but /user returns 410: InvalidApiVersion (not TokenInvalid)' { + InModuleScope Maester { + $__MtSession.MaesterConfig = [PSCustomObject]@{ + GlobalSettings = [PSCustomObject]@{ + GitHubApiVersion = '2020-01-01' + } + } + } + $fakeResp = [PSCustomObject]@{ StatusCode = 410; Headers = @{} } + $ex = [System.Exception]::new('Gone') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 410 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'API version is not supported' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } + + Connect-MtGitHub -Organization 'myorg' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiVersion' + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + + It 'Parameter -ApiVersion "latest" sets InvalidApiVersion and clears prior session state' { + # Pre-seed a stale session to prove the function clears it before failing. + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'stale' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer stale-token' } + } + Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiVersion is invalid' } + + Connect-MtGitHub -Organization 'myorg' -ApiVersion 'latest' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiVersion' + # Stale auth header must be cleared even though the failure path runs early. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + } + + It '/user 400 with "Not a supported version" message maps to InvalidApiVersion (not TokenInvalid)' { + $fakeResp = [PSCustomObject]@{ StatusCode = 400; Headers = @{} } + $ex = [System.Exception]::new('Bad Request') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 400 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Not a supported version' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } + + Connect-MtGitHub -Organization 'myorg' -ApiVersion '2024-01-01' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiVersion' + } + } + + It 'Parameter -ApiVersion "2024-01-01" passes local format validation and reaches /user header' { + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { + [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/memberships/' } { + [PSCustomObject]@{ Content = '{"state":"active","role":"admin"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + [PSCustomObject]@{ Content = '{"login":"testuser"}' } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/orgs/[^/]+$' } { + [PSCustomObject]@{ Content = '{"login":"myorg"}' } + } + + Connect-MtGitHub -Organization 'myorg' -ApiVersion '2024-01-01' 3>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeTrue + $c.ApiVersion | Should -Be '2024-01-01' + $__MtSession.GitHubAuthHeader['X-GitHub-Api-Version'] | Should -Be '2024-01-01' + } + } + } } From d8764e0a7ce07d58fec95880ef2f1528ad025885 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Fri, 8 May 2026 02:42:24 +0000 Subject: [PATCH 13/24] fix: classify Connect-MtGitHub /user failures by transport vs token - Remove [ValidatePattern] on -ApiBaseUri so invalid parameter values flow through the existing in-body URI check after session state is cleared, instead of throwing before the cleanup runs. - Split /user error classification: 401 stays TokenInvalid, $null status code (DNS/TLS/connect failure) becomes ApiBaseUriFailed, and 5xx becomes a new ApiUnavailable reason. Host messages no longer describe transport or service failures as token validation failures. - Refresh stale comments that referenced the removed [ValidatePattern]. - Add regression tests for invalid -ApiBaseUri parameter values, /user transport failures (no Response), and /user 5xx responses. Co-Authored-By: Claude Opus 4.7 --- powershell/public/core/Connect-MtGitHub.ps1 | 30 ++++- .../functions/Connect-MtGitHub.Tests.ps1 | 111 ++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/powershell/public/core/Connect-MtGitHub.ps1 b/powershell/public/core/Connect-MtGitHub.ps1 index 899c1edf3..44e6a3051 100644 --- a/powershell/public/core/Connect-MtGitHub.ps1 +++ b/powershell/public/core/Connect-MtGitHub.ps1 @@ -81,7 +81,6 @@ [securestring] $Token, [Parameter(Mandatory = $false)] - [ValidatePattern('^https://')] [string] $ApiBaseUri, [Parameter(Mandatory = $false)] @@ -114,9 +113,7 @@ return } - # Resolve ApiBaseUri (param -> config -> default) into a local variable. Reassigning - # $ApiBaseUri would re-trigger [ValidatePattern] and short-circuit the config path with - # a hard exception instead of our InvalidApiBaseUri failure. + # Resolve ApiBaseUri (param -> config -> default). $resolvedApiBaseUri = $ApiBaseUri if ([string]::IsNullOrWhiteSpace($resolvedApiBaseUri)) { $configApiBaseUri = Get-MtMaesterConfigGlobalSetting -SettingName 'GitHubApiBaseUri' @@ -125,8 +122,9 @@ if ([string]::IsNullOrWhiteSpace($resolvedApiBaseUri)) { $resolvedApiBaseUri = 'https://api.github.com' } $resolvedApiBaseUri = $resolvedApiBaseUri.TrimEnd('/') - # Revalidate the fully-resolved URI. [ValidatePattern] on the param only fires when -ApiBaseUri - # is bound, so a config value can otherwise reach Invoke-WebRequest with a Bearer header on http://. + # Validate the fully-resolved URI. Done in-body (not via parameter [ValidatePattern]) so + # config-supplied values are checked too, and so an invalid value records InvalidApiBaseUri + # after the session-clearing logic at the top of this function rather than throwing before it. $parsedUri = $null if (-not [uri]::TryCreate($resolvedApiBaseUri, [UriKind]::Absolute, [ref]$parsedUri) -or $parsedUri.Scheme -cne 'https') { Write-Host "`nGitHub API base URI must be an absolute https:// URI. Got: '$resolvedApiBaseUri'." -ForegroundColor Red @@ -200,6 +198,26 @@ $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'InvalidApiVersion' } return } + # $null status code means no HTTP response was produced (DNS failure, TLS handshake + # failure, connection refused, hostname unreachable). The PAT was never evaluated and + # the URI itself didn't resolve to a working endpoint — commonly a wrong GHE base URI. + if ($null -eq $code) { + Write-Host "`nGitHub API base URI '$ApiBaseUri' is not reachable (no response). Verify network connectivity, DNS/TLS, and the GitHubApiBaseUri value (use https://api.{subdomain}.ghe.com for GHE.com)." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'ApiBaseUriFailed' } + return + } + # 5xx means GitHub responded but the endpoint is failing — the URI is fine, the + # service is unavailable. Don't conflate with token or base-URI problems. + if ($code -ge 500 -and $code -le 599) { + Write-Host "`nGitHub API request failed (HTTP $code). The GitHub service may be temporarily unavailable; check https://www.githubstatus.com/ and retry." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'ApiUnavailable' } + return + } + if ($code -eq 401) { + Write-Host "`nGitHub token validation failed (HTTP 401). Verify the PAT is valid and not expired." -ForegroundColor Red + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'TokenInvalid' } + return + } Write-Host "`nGitHub token validation failed (HTTP $code). Verify the PAT is valid and not expired." -ForegroundColor Red $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false; FailureReason = 'TokenInvalid' } return diff --git a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 index b34b3d97a..7d79b37b3 100644 --- a/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 +++ b/powershell/tests/functions/Connect-MtGitHub.Tests.ps1 @@ -71,6 +71,78 @@ Describe 'Connect-MtGitHub' { } } + Context 'Failure: ApiBaseUriFailed' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + } + + It 'Sets FailureReason = ApiBaseUriFailed when /user throws with no Response/StatusCode (DNS/TLS/transport)' { + # Plain exception with no Response — Get-MtGitHubErrorStatusCode returns $null, + # which models DNS failure, TLS handshake failure, connection refused, or an + # unreachable GHE base URI. Must not be classified as TokenInvalid. + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { + throw [System.Exception]::new('No such host is known') + } + + Connect-MtGitHub -Organization 'myorg' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'ApiBaseUriFailed' + $c.FailureReason | Should -Not -Be 'TokenInvalid' + # Security property: failed connection must not leave a Bearer header in session. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + } + + Context 'Failure: ApiUnavailable' { + BeforeEach { + $env:MAESTER_GITHUB_TOKEN = 'valid-token' + } + + It 'Sets FailureReason = ApiUnavailable when /user returns HTTP 500 (server error, URI is fine)' { + # 5xx means GitHub responded — the base URI resolved and TLS succeeded — but the + # service itself is failing. Must not be conflated with TokenInvalid or ApiBaseUriFailed. + $fakeResp = [PSCustomObject]@{ StatusCode = 500; Headers = @{} } + $ex = [System.Exception]::new('Internal Server Error') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 500 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Internal Server Error' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } + + Connect-MtGitHub -Organization 'myorg' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'ApiUnavailable' + $c.FailureReason | Should -Not -Be 'TokenInvalid' + $c.FailureReason | Should -Not -Be 'ApiBaseUriFailed' + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + + It 'Sets FailureReason = ApiUnavailable when /user returns HTTP 503' { + $fakeResp = [PSCustomObject]@{ StatusCode = 503; Headers = @{} } + $ex = [System.Exception]::new('Service Unavailable') + Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp + Mock Get-MtGitHubErrorStatusCode -ModuleName Maester { 503 } + Mock Get-MtGitHubErrorMessage -ModuleName Maester { 'Service Unavailable' } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/user$' } { throw $ex } + + Connect-MtGitHub -Organization 'myorg' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'ApiUnavailable' + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + } + } + Context 'Failure: OrgAccessFailed' { BeforeEach { $env:MAESTER_GITHUB_TOKEN = 'valid-token' @@ -479,6 +551,45 @@ Describe 'Connect-MtGitHub' { Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } + It 'Parameter -ApiBaseUri http:// fails before any web request and clears prior session state' { + # Pre-seed a stale session to prove the parameter validation path no longer throws + # before the session-clear step at the top of Connect-MtGitHub runs. + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'stale' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer stale-token' } + } + Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } + + Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'http://api.example.com' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiBaseUri' + # Stale session state must be cleared even when the parameter value is rejected. + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + } + + It 'Parameter -ApiBaseUri "not-a-uri" fails before any web request and clears prior session state' { + InModuleScope Maester { + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'stale' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer stale-token' } + } + Mock Invoke-WebRequest -ModuleName Maester { throw 'Invoke-WebRequest must not be called when ApiBaseUri is invalid' } + + Connect-MtGitHub -Organization 'myorg' -ApiBaseUri 'not-a-uri' 6>$null + + InModuleScope Maester { + $c = $__MtSession.GitHubConnection + $c.Connected | Should -BeFalse + $c.FailureReason | Should -Be 'InvalidApiBaseUri' + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + } + It 'Parameter https URI with trailing slash still works and is trimmed' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '/actions/permissions$' } { [PSCustomObject]@{ Content = '{}'; StatusCode = 200 } From e0b6514b786cc1e5b96759dfb72b3f8485ca5e18 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Sat, 9 May 2026 03:20:43 +0000 Subject: [PATCH 14/24] feat: render safe GitHub metadata in Test-MtConnection -Details Adds a GitHub ListItem to the Maester.Connections list view so Test-MtConnection -Service GitHub -Details visibly renders connection details. The formatter only displays safe metadata (Connected, Organization, TokenLogin, ApiBaseUri, Role, RoleState, AdministrationPermissionVerified) and never touches the auth header, Authorization, or token values. Co-Authored-By: Claude Opus 4.7 --- powershell/Maester.Format.ps1xml | 22 ++++++++++++++ .../functions/Test-MtConnection.Tests.ps1 | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/powershell/Maester.Format.ps1xml b/powershell/Maester.Format.ps1xml index 57eb7dd89..dfeeddb68 100644 --- a/powershell/Maester.Format.ps1xml +++ b/powershell/Maester.Format.ps1xml @@ -103,6 +103,28 @@ } + + + + $null -ne $_.GitHub + + + + + 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 { + '' + } + + diff --git a/powershell/tests/functions/Test-MtConnection.Tests.ps1 b/powershell/tests/functions/Test-MtConnection.Tests.ps1 index 58208f360..5d95a0c59 100644 --- a/powershell/tests/functions/Test-MtConnection.Tests.ps1 +++ b/powershell/tests/functions/Test-MtConnection.Tests.ps1 @@ -141,6 +141,35 @@ Describe 'Test-MtConnection — GitHub service' { } } + Context '-Service GitHub -Details formatted output' { + It 'Renders safe GitHub metadata and excludes auth/token values' { + $fakeToken = 'ghp_FAKE_TOKEN_VALUE_DO_NOT_USE' + InModuleScope Maester -ArgumentList $fakeToken { + param($FakeToken) + $__MtSession.GitHubConnection = [PSCustomObject]@{ + Connected = $true + Organization = 'myorg' + TokenLogin = 'octocat' + ApiBaseUri = 'https://api.github.com' + ApiVersion = '2022-11-28' + Role = 'admin' + RoleState = 'active' + AdministrationPermissionVerified = $true + } + $__MtSession.GitHubAuthHeader = @{ + Authorization = "Bearer $FakeToken" + } + } + $rendered = Test-MtConnection -Service GitHub -Details | Out-String + $rendered | Should -Match 'GitHub' + $rendered | Should -Match 'myorg' + $rendered | Should -Match 'octocat' + $rendered | Should -Not -Match 'Bearer' + $rendered | Should -Not -Match 'Authorization' + $rendered | Should -Not -Match ([regex]::Escape($fakeToken)) + } + } + Context '-Service All — GitHub included when connected' { It 'Populates the GitHub property' { InModuleScope Maester { From b97ae8c56b769fc56a600ce2d79de254b622fa02 Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Sat, 9 May 2026 03:20:51 +0000 Subject: [PATCH 15/24] fix: always clear GitHub state in Disconnect-Maester via try/finally Compute the invocation-name check up front, then wrap the existing Graph/Azure/EXO/Teams disconnect calls in try/finally so Disconnect-MtGitHub runs even when an earlier service-disconnect throws. Original exceptions still propagate. Disconnect-MtGraph alias keeps its Graph-only semantic and does not clear GitHub. Co-Authored-By: Claude Opus 4.7 --- powershell/public/Disconnect-Maester.ps1 | 56 ++++++++++--------- .../functions/Disconnect-Maester.Tests.ps1 | 20 +++++++ 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/powershell/public/Disconnect-Maester.ps1 b/powershell/public/Disconnect-Maester.ps1 index 74667ded2..860b55168 100644 --- a/powershell/public/Disconnect-Maester.ps1 +++ b/powershell/public/Disconnect-Maester.ps1 @@ -31,38 +31,44 @@ [CmdletBinding()] param() - if($__MtSession.Connections -contains "Graph" -or $__MtSession.Connections -contains "All"){ - Write-Verbose -Message "Disconnecting from Microsoft Graph." - Disconnect-MgGraph - } - - if($__MtSession.Connections -contains "Azure" -or $__MtSession.Connections -contains "Dataverse" -or $__MtSession.Connections -contains "All"){ - Write-Verbose -Message "Disconnecting from Microsoft Azure." - try { - Disconnect-AzAccount -ErrorAction Stop | Out-Null - } catch { - Write-Verbose "Disconnect-AzAccount encountered an error: $($_.Exception.Message)" - } - } - - if($__MtSession.Connections -contains "ExchangeOnline" -or $__MtSession.Connections -contains "SecurityCompliance" -or $__MtSession.Connections -contains "All"){ - Write-Verbose -Message "Disconnecting from Microsoft Exchange Online." - Disconnect-ExchangeOnline - } - if($__MtSession.Connections -contains "Teams" -or $__MtSession.Connections -contains "All"){ - Write-Verbose -Message "Disconnecting from Microsoft Teams." - Disconnect-MicrosoftTeams - } - # Strip module qualifier (e.g. 'Maester\Disconnect-Maester') so module-qualified # invocation still routes to the GitHub-clearing branch. PowerShell uses '\' for # the qualifier on all OSes, so split on the literal char rather than Split-Path. + # Computed up-front so a failure in any service-disconnect call below cannot + # leave GitHub state behind: cleanup runs from the finally block. $invokedAs = if ([string]::IsNullOrEmpty($MyInvocation.InvocationName)) { $MyInvocation.MyCommand.Name } else { ($MyInvocation.InvocationName -split '\\')[-1] } - if ($invokedAs -iin @('Disconnect-Maester','Disconnect-MtMaester')) { - Disconnect-MtGitHub + $shouldDisconnectGitHub = $invokedAs -iin @('Disconnect-Maester','Disconnect-MtMaester') + + try { + if($__MtSession.Connections -contains "Graph" -or $__MtSession.Connections -contains "All"){ + Write-Verbose -Message "Disconnecting from Microsoft Graph." + Disconnect-MgGraph + } + + if($__MtSession.Connections -contains "Azure" -or $__MtSession.Connections -contains "Dataverse" -or $__MtSession.Connections -contains "All"){ + Write-Verbose -Message "Disconnecting from Microsoft Azure." + try { + Disconnect-AzAccount -ErrorAction Stop | Out-Null + } catch { + Write-Verbose "Disconnect-AzAccount encountered an error: $($_.Exception.Message)" + } + } + + if($__MtSession.Connections -contains "ExchangeOnline" -or $__MtSession.Connections -contains "SecurityCompliance" -or $__MtSession.Connections -contains "All"){ + Write-Verbose -Message "Disconnecting from Microsoft Exchange Online." + Disconnect-ExchangeOnline + } + if($__MtSession.Connections -contains "Teams" -or $__MtSession.Connections -contains "All"){ + Write-Verbose -Message "Disconnecting from Microsoft Teams." + Disconnect-MicrosoftTeams + } + } finally { + if ($shouldDisconnectGitHub) { + Disconnect-MtGitHub + } } } diff --git a/powershell/tests/functions/Disconnect-Maester.Tests.ps1 b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 index eb755a51e..5c2bcb751 100644 --- a/powershell/tests/functions/Disconnect-Maester.Tests.ps1 +++ b/powershell/tests/functions/Disconnect-Maester.Tests.ps1 @@ -83,6 +83,26 @@ Describe 'Disconnect-Maester — GitHub cleanup branch' { } } + Context 'When Graph disconnect throws' { + It 'Still clears GitHub state and rethrows the original exception' { + InModuleScope Maester { + $__MtSession.Connections = @('Graph') + $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true; Organization = 'myorg' } + $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer token' } + $__MtSession.GitHubCache = @{ 'foo' = 'bar' } + } + Mock Disconnect-MgGraph -ModuleName Maester { throw 'Graph disconnect blew up' } + + { Disconnect-Maester 6>$null } | Should -Throw '*Graph disconnect blew up*' + + InModuleScope Maester { + $__MtSession.GitHubConnection | Should -BeNullOrEmpty + $__MtSession.GitHubAuthHeader | Should -BeNullOrEmpty + $__MtSession.GitHubCache.Count | Should -Be 0 + } + } + } + Context 'When no GitHub state exists' { It 'Produces no GitHub-related host output' { $hostOutput = Disconnect-Maester 6>&1 | Out-String From 4cc58b7ac6d89c8241f09655f43f22b74815975c Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Sat, 9 May 2026 03:21:04 +0000 Subject: [PATCH 16/24] fix: refuse cross-origin Link rel=next in Invoke-MtGitHubRequest A malicious or buggy upstream that injects a foreign URL into the Link header would otherwise receive the Authorization header on a cross-origin request. Validate each next URL against the configured ApiBaseUri (scheme, host, port, and base path prefix so GHE-style https://host/api/v3 bases work) and throw before issuing the request when the origin does not match. Co-Authored-By: Claude Opus 4.7 --- .../internal/Invoke-MtGitHubRequest.ps1 | 21 +++++++++++ .../Invoke-MtGitHubRequest.Tests.ps1 | 36 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/powershell/internal/Invoke-MtGitHubRequest.ps1 b/powershell/internal/Invoke-MtGitHubRequest.ps1 index 10b9a0c62..9547dfc9f 100644 --- a/powershell/internal/Invoke-MtGitHubRequest.ps1 +++ b/powershell/internal/Invoke-MtGitHubRequest.ps1 @@ -104,6 +104,24 @@ 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) { @@ -113,6 +131,9 @@ $all.AddRange(@($first.Body)) $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 $all.AddRange(@($page.Body)) $next = Get-NextLink $page.Link diff --git a/powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 b/powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 index 48fc520c9..cdba0eeb8 100644 --- a/powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 +++ b/powershell/tests/functions/Invoke-MtGitHubRequest.Tests.ps1 @@ -123,6 +123,42 @@ Describe 'Invoke-MtGitHubRequest' { } } + Context 'Pagination cross-origin guard' { + It 'Throws and stops paginating when next link is on a different origin' { + Mock Invoke-WebRequest -ModuleName Maester { + [PSCustomObject]@{ + Content = '[{"id":1}]' + Headers = @{ 'Link' = '; rel="next"' } + } + } + InModuleScope Maester { + { Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate } | + Should -Throw '*outside the configured ApiBaseUri*' + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 1 + } + + It 'Allows a same-origin next link with a base path prefix (GHE-style /api/v3)' { + InModuleScope Maester { + $__MtSession.GitHubConnection.ApiBaseUri = 'https://ghe.example.com/api/v3' + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -notmatch '[?&]page=\d' } { + [PSCustomObject]@{ + Content = '[{"id":1}]' + Headers = @{ 'Link' = '; rel="next"' } + } + } + Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '[?&]page=\d' } { + [PSCustomObject]@{ Content = '[{"id":2}]'; Headers = @{} } + } + InModuleScope Maester { + $result = Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate + $result.Count | Should -Be 2 + } + Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 2 + } + } + Context 'Rate limit handling' { It 'Emits verbose message when x-ratelimit-remaining is 0 on successful response' { Mock Invoke-WebRequest -ModuleName Maester { From 17ac2edae13e9987c633797806685d3368824c9a Mon Sep 17 00:00:00 2001 From: Travis McDade Date: Sat, 9 May 2026 03:44:51 +0000 Subject: [PATCH 17/24] fix: classify GitHub rate-limit responses in Connect-MtGitHub probes Connect-MtGitHub bootstrap probes used raw Invoke-WebRequest catches that did not detect GitHub primary/secondary rate-limit responses. A valid token receiving HTTP 403/429 with rate-limit headers could be reported as TokenInvalid, OrgAccessFailed, OrgMembershipFailed, or as a misleading admin-permission warning. Extract rate-limit detection into Get-MtGitHubRateLimitMessage so both Invoke-MtGitHubRequest and Connect-MtGitHub share one implementation. The helper uses [int]::TryParse / [long]::TryParse so a malformed x-ratelimit-remaining or x-ratelimit-reset header cannot mask the underlying error with a parse exception. Blocking probes (/user, /orgs/{org}, /orgs/{org}/memberships/{user}) short-circuit on rate limit with FailureReason = 'RateLimited' and do not store the auth header. The non-blocking admin probe keeps the session connected but records a rate-limit-specific failure reason and warning instead of implying missing permissions. Co-Authored-By: Claude Opus 4.7 --- .../internal/Get-MtGitHubRateLimitMessage.ps1 | 61 ++++++++++ .../internal/Invoke-MtGitHubRequest.ps1 | 25 +--- powershell/public/core/Connect-MtGitHub.ps1 | 34 +++++- .../functions/Connect-MtGitHub.Tests.ps1 | 107 ++++++++++++++++++ .../Get-MtGitHubRateLimitMessage.Tests.ps1 | 102 +++++++++++++++++ .../Invoke-MtGitHubRequest.Tests.ps1 | 37 ++++++ 6 files changed, 338 insertions(+), 28 deletions(-) create mode 100644 powershell/internal/Get-MtGitHubRateLimitMessage.ps1 create mode 100644 powershell/tests/functions/Get-MtGitHubRateLimitMessage.Tests.ps1 diff --git a/powershell/internal/Get-MtGitHubRateLimitMessage.ps1 b/powershell/internal/Get-MtGitHubRateLimitMessage.ps1 new file mode 100644 index 000000000..52cc05533 --- /dev/null +++ b/powershell/internal/Get-MtGitHubRateLimitMessage.ps1 @@ -0,0 +1,61 @@ +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 ). Resets at: