diff --git a/powershell/internal/Get-MtGitHubRepoFromGit.ps1 b/powershell/internal/Get-MtGitHubRepoFromGit.ps1 new file mode 100644 index 000000000..6cfd89ecd --- /dev/null +++ b/powershell/internal/Get-MtGitHubRepoFromGit.ps1 @@ -0,0 +1,65 @@ +function Get-MtGitHubRepoFromGit { + <# + .SYNOPSIS + Detects the current GitHub repository from the local git remote (origin). + + .DESCRIPTION + Used by Add-MtMaesterAppFederatedCredential and New-MtMaesterApp to remove + the need for the user to specify -GitHubOrganization and -GitHubRepository + when running the command from inside a git working tree whose `origin` remote + points at GitHub. + + Supports HTTPS, scp-style SSH, and ssh:// remote URL formats: + https://github.com/owner/repo.git + https://github.com/owner/repo + https://www.github.com/owner/repo.git + git@github.com:owner/repo.git + ssh://git@github.com/owner/repo.git + + Returns $null when: + * git is not installed / not on PATH + * the current directory is not inside a git working tree + * the origin remote is not a GitHub URL + * the URL cannot be parsed + + .OUTPUTS + [pscustomobject] with Organization, Repository, RemoteUrl properties, or $null. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # Optional override for the git remote name. Defaults to 'origin'. + [string] $RemoteName = 'origin' + ) + + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Verbose "git is not available on PATH; cannot auto-detect GitHub repo." + return $null + } + + try { + $remoteUrl = (& git remote get-url $RemoteName 2>$null) | Select-Object -First 1 + } catch { + Write-Verbose "Failed to read git remote '$RemoteName': $($_.Exception.Message)" + return $null + } + + if ([string]::IsNullOrWhiteSpace($remoteUrl)) { + Write-Verbose "git remote '$RemoteName' is not configured." + return $null + } + + # Match HTTPS, SSH, and scp-style GitHub URLs. Host anchored to github.com (optional 'www.') + # so lookalikes such as 'evilgithub.com' or 'github.com.attacker.com' do not match. + $pattern = '^(?:https?://(?:www\.)?github\.com/|git@github\.com:|ssh://git@github\.com/)([^/]+)/([^/]+?)(?:\.git)?/?\s*$' + if ($remoteUrl -notmatch $pattern) { + Write-Verbose "Remote URL '$remoteUrl' is not a recognised GitHub URL." + return $null + } + + return [pscustomobject]@{ + Organization = $Matches[1] + Repository = $Matches[2] + RemoteUrl = $remoteUrl.Trim() + } +} diff --git a/powershell/internal/Set-MtGitHubActionsSecret.ps1 b/powershell/internal/Set-MtGitHubActionsSecret.ps1 new file mode 100644 index 000000000..ae050d90a --- /dev/null +++ b/powershell/internal/Set-MtGitHubActionsSecret.ps1 @@ -0,0 +1,72 @@ +function Set-MtGitHubActionsSecret { + <# + .SYNOPSIS + Sets AZURE_CLIENT_ID and AZURE_TENANT_ID as GitHub Actions repository secrets via the GitHub CLI. + + .DESCRIPTION + Used by Add-MtMaesterAppFederatedCredential when -SetGitHubSecrets is specified. + Returns $true when both secrets were set successfully, $false otherwise (caller + should fall back to printing manual setup instructions). + + Requires the GitHub CLI (`gh`) to be installed and authenticated. Will validate + both before attempting any state-changing call. + + .OUTPUTS + [bool] - $true on success, $false if gh is missing/unauthenticated or any + `gh secret set` call fails. + #> + [CmdletBinding()] + [OutputType([bool])] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Colors are beautiful')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'User opted in via -SetGitHubSecrets switch on the calling cmdlet')] + param( + # Target repository in 'owner/repo' format. + [Parameter(Mandatory = $true)] + [string] $GitHubRepository, + + # Application (Client) ID to store as AZURE_CLIENT_ID. + [Parameter(Mandatory = $true)] + [string] $ClientId, + + # Entra Tenant ID to store as AZURE_TENANT_ID. + [Parameter(Mandatory = $true)] + [string] $TenantId + ) + + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + Write-Warning "GitHub CLI ('gh') is not installed or not on PATH. Falling back to manual instructions." + Write-Host "Install gh from https://cli.github.com/ to enable -SetGitHubSecrets." -ForegroundColor DarkGray + return $false + } + + # Validate gh auth - 'gh auth status' exits 0 when authenticated. + & gh auth status 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "GitHub CLI is not authenticated. Run 'gh auth login' first. Falling back to manual instructions." + return $false + } + + Write-Host "Setting GitHub Actions secrets on $GitHubRepository via gh CLI..." -ForegroundColor Yellow + + $secrets = [ordered]@{ + AZURE_CLIENT_ID = $ClientId + AZURE_TENANT_ID = $TenantId + } + + foreach ($name in $secrets.Keys) { + $value = $secrets[$name] + # Pipe the value via stdin so the secret never appears on the gh command line + # (process listings, audit logs, shell history would otherwise capture it). + $output = $value | & gh secret set $name --repo $GitHubRepository 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to set $name on $GitHubRepository : $output" + return $false + } + Write-Host " ✓ $name set" -ForegroundColor Green + } + + Write-Host "" + Write-Host "✅ AZURE_CLIENT_ID and AZURE_TENANT_ID configured on $GitHubRepository." -ForegroundColor Green + Write-Host "" + return $true +} diff --git a/powershell/internal/Write-MtGitHubSecretsManualInstruction.ps1 b/powershell/internal/Write-MtGitHubSecretsManualInstruction.ps1 new file mode 100644 index 000000000..223899c2d --- /dev/null +++ b/powershell/internal/Write-MtGitHubSecretsManualInstruction.ps1 @@ -0,0 +1,46 @@ +function Write-MtGitHubSecretsManualInstruction { + <# + .SYNOPSIS + Prints the manual GitHub Actions secrets setup instructions used as a fallback by + Add-MtMaesterAppFederatedCredential when -SetGitHubSecrets is not used or when the + GitHub CLI (`gh`) is unavailable / unauthenticated / fails. + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Colors are beautiful')] + param( + [Parameter(Mandatory = $true)] [string] $GitHubOrganization, + [Parameter(Mandatory = $true)] [string] $GitHubRepository, + [Parameter(Mandatory = $true)] [string] $ClientId, + [Parameter(Mandatory = $true)] [string] $TenantId, + + # When $true the caller already attempted -SetGitHubSecrets and the gh CLI path + # failed (missing / unauthenticated / call failure). In that case suggesting the + # user re-run with the same switch is misleading - show a `gh` troubleshooting tip + # instead. + [switch] $AttemptedAutomatic + ) + + $githubSecretsUrl = "https://github.com/$GitHubOrganization/$GitHubRepository/settings/secrets/actions" + + Write-Host "GitHub Actions Configuration:" -ForegroundColor Yellow + Write-Host "Add these secrets to your GitHub repository ($GitHubOrganization/$GitHubRepository):" -ForegroundColor White + Write-Host "" + Write-Host "1. Browse to $githubSecretsUrl" -ForegroundColor Cyan + Write-Host "2. Click on 'New repository secret'" -ForegroundColor Cyan + Write-Host "3. Create the following secrets:" -ForegroundColor Cyan + Write-Host "" + Write-Host " Name: AZURE_CLIENT_ID" -ForegroundColor Cyan + Write-Host " Value: $ClientId" -ForegroundColor Cyan + Write-Host " Name: AZURE_TENANT_ID" -ForegroundColor Cyan + Write-Host " Value: $TenantId" -ForegroundColor Cyan + Write-Host "" + if ($AttemptedAutomatic) { + Write-Host "Tip: -SetGitHubSecrets was requested but the GitHub CLI ('gh') was unavailable or" -ForegroundColor DarkGray + Write-Host " failed. Install gh from https://cli.github.com/, run 'gh auth login', then re-run" -ForegroundColor DarkGray + Write-Host " this command to push the secrets automatically." -ForegroundColor DarkGray + } else { + Write-Host "Tip: re-run with -SetGitHubSecrets to push these via the GitHub CLI automatically." -ForegroundColor DarkGray + } + Write-Host "See https://maester.dev/docs/monitoring/github#add-entra-tenant-info-to-github-repos for details." -ForegroundColor Yellow + Write-Host "" +} diff --git a/powershell/public/core/Add-MtMaesterAppFederatedCredential.ps1 b/powershell/public/core/Add-MtMaesterAppFederatedCredential.ps1 index 15fabbc4a..6c3973d8c 100644 --- a/powershell/public/core/Add-MtMaesterAppFederatedCredential.ps1 +++ b/powershell/public/core/Add-MtMaesterAppFederatedCredential.ps1 @@ -16,16 +16,28 @@ The Application (Client) ID of the Maester application to add the federated credential to. .PARAMETER GitHubRepository - The GitHub repository in the format 'owner/repo' (e.g., 'myorg/myrepo'). + The GitHub repository name (without the organization). E.g. maester-tests. + If both -GitHubOrganization and -GitHubRepository are omitted and the current working + directory is inside a git repository whose 'origin' remote points at GitHub, both + values are auto-detected from `git remote get-url origin`. Specifying one without + the other is not supported - either pass both explicitly, or pass neither and rely + on auto-detection. .PARAMETER GitHubBranch The GitHub branch that can use this credential. Defaults to 'main'. .PARAMETER Name - The name for the federated credential. Defaults to 'maester-devops'. + The name for the federated credential. Defaults to 'maester-devops--'. - .PARAMETER Force - Skip the confirmation prompt if a similar credential already exists. + .PARAMETER SetGitHubSecrets + If specified, sets the AZURE_CLIENT_ID and AZURE_TENANT_ID secrets on the target + GitHub repository using the GitHub CLI (`gh`). Requires `gh` to be installed and + authenticated (`gh auth login`). When the secrets cannot be set automatically the + cmdlet falls back to printing the manual setup instructions. + + Re-running the cmdlet with -SetGitHubSecrets against an app that already has a + matching federated credential will skip the credential creation step and proceed + directly to (re)setting the secrets. .EXAMPLE Add-MtMaesterAppFederatedCredential -AppId "12345678-1234-1234-1234-123456789012" -GitHubOrganization "myorg" -GitHubRepository "myrepo" @@ -37,6 +49,13 @@ Adds a federated credential for the develop branch with a custom name. + .EXAMPLE + Add-MtMaesterAppFederatedCredential -AppId "12345678-1234-1234-1234-123456789012" -SetGitHubSecrets + + Auto-detects the GitHub organization and repository from the current git remote, adds + the federated credential, and pushes AZURE_CLIENT_ID / AZURE_TENANT_ID to the repo's + Actions secrets via the GitHub CLI. + .LINK https://maester.dev/docs/commands/Add-MtMaesterAppFederatedCredential #> @@ -53,14 +72,15 @@ [Alias('ClientId')] [string] $AppId, - # Your GitHub organization name or GitHub username. E.g. jasonf - [Parameter(Mandatory = $true, ParameterSetName = 'ById')] - [Parameter(Mandatory = $true, ParameterSetName = 'ByApplicationId')] + # Your GitHub organization name or GitHub username. E.g. jasonf. + # If omitted (together with -GitHubRepository) the value is auto-detected from + # the local git remote ('origin') when the current directory is a git repo. + [Parameter(Mandatory = $false)] [string] $GitHubOrganization, - # Your GitHub repository name where the GitHub Actions workflow is located. E.g. maester-tests - [Parameter(Mandatory = $true, ParameterSetName = 'ById')] - [Parameter(Mandatory = $true, ParameterSetName = 'ByApplicationId')] + # Your GitHub repository name where the GitHub Actions workflow is located. E.g. maester-tests. + # Auto-detected from the local git remote ('origin') when omitted. + [Parameter(Mandatory = $false)] [string] $GitHubRepository, # The GitHub branch that can use this credential @@ -69,13 +89,39 @@ # The name for the federated credential [Parameter(Mandatory = $false)] - [string] $Name + [string] $Name, + + # If set, also pushes AZURE_CLIENT_ID and AZURE_TENANT_ID to the GitHub repo's + # Actions secrets using the GitHub CLI (`gh`). Falls back to printing manual + # instructions if `gh` is not installed or not authenticated. + [Parameter(Mandatory = $false)] + [switch] $SetGitHubSecrets ) if (-not (Test-MtAzContext)) { return } + # Auto-detect GitHub org/repo from the local git remote. Only triggers when BOTH + # parameters were omitted - mixing an explicit value with an auto-detected one is + # ambiguous (which repo did the caller really mean?) so we require both-or-neither. + if (-not $GitHubOrganization -and -not $GitHubRepository) { + $detected = Get-MtGitHubRepoFromGit + if ($detected) { + $GitHubOrganization = $detected.Organization + $GitHubRepository = $detected.Repository + Write-Host "Auto-detected GitHub repository from git remote: $GitHubOrganization/$GitHubRepository" -ForegroundColor Cyan + } + } elseif (-not $GitHubOrganization -or -not $GitHubRepository) { + Write-Error "Specify both -GitHubOrganization and -GitHubRepository, or omit both to auto-detect from the local git remote." + return + } + + if (-not $GitHubOrganization -or -not $GitHubRepository) { + Write-Error "GitHubOrganization and GitHubRepository are required. They can be auto-detected when the current directory is a git working tree whose 'origin' remote points at GitHub." + return + } + try { if ($Id) { $params = @{ Id = $Id } @@ -111,13 +157,25 @@ } if ($duplicateName -or $duplicateSubject) { - if($duplicateSubject) { - Write-Error "A federated credential for this repository already exists:" - $duplicateCred += $duplicateSubject + # duplicateSubject = an existing credential already grants the requested + # repo/branch. That is not a failure - the desired end state already exists, + # so we treat re-runs as idempotent (and continue to secrets setup when asked). + # + # duplicateName without duplicateSubject = the name is taken by a credential + # for a DIFFERENT repo/branch. The requested credential cannot be created, + # so this remains a hard error so callers / automation detect the failure. + if ($duplicateSubject) { + Write-Warning "A federated credential for this repository already exists:" + $duplicateCred = $duplicateSubject } - elseif($duplicateName) { - Write-Error "A federated credential with this name already exists:" - $duplicateCred = $duplicateName + elseif ($duplicateName) { + Write-Error "A federated credential with the name '$Name' already exists for a different repository or branch. Choose a different -Name or remove the existing credential." + $duplicateName | ForEach-Object { + Write-Host " Name: $($_.name)" -ForegroundColor Yellow + Write-Host " Subject: $($_.subject)" -ForegroundColor Yellow + Write-Host "" + } + return } $duplicateCred | ForEach-Object { @@ -125,6 +183,20 @@ Write-Host " Subject: $($_.subject)" -ForegroundColor Yellow Write-Host "" } + + # If the existing credential already matches the requested repo/branch and the + # caller asked us to also set secrets, do that work instead of silently returning. + # This makes `-SetGitHubSecrets` idempotent on re-runs. + if ($duplicateSubject -and $SetGitHubSecrets) { + Write-Host "Existing credential matches - proceeding to (re)set GitHub Actions secrets." -ForegroundColor Cyan + $tenantId = (Get-AzContext).Tenant.Id + $secretsConfigured = Set-MtGitHubActionsSecret -GitHubRepository "$GitHubOrganization/$GitHubRepository" -ClientId $app.AppId -TenantId $tenantId + if (-not $secretsConfigured) { + Write-MtGitHubSecretsManualInstruction -GitHubOrganization $GitHubOrganization -GitHubRepository $GitHubRepository -ClientId $app.AppId -TenantId $tenantId -AttemptedAutomatic + } + return $duplicateSubject + } + return } @@ -145,24 +217,27 @@ throw "Failed to create federated credential. Error: $($createdCredential.error.message)" } - $githubSecretsUrl = "https://github.com/$GitHubOrganization/$GitHubRepository/settings/secrets/actions" + $tenantId = (Get-AzContext).Tenant.Id + Write-Host "" Write-Host "🎉 Federated credential created successfully!" -ForegroundColor Green Write-Host "" - Write-Host "GitHub Actions Configuration:" -ForegroundColor Yellow - Write-Host "Add these secrets to your GitHub repository ($GitHubOrganization/$GitHubRepository):" -ForegroundColor White - Write-Host "" - Write-Host "1. Browse to $githubSecretsUrl" -ForegroundColor Cyan - Write-Host "2. Click on 'New repository secret'" -ForegroundColor Cyan - Write-Host "3. Create the following secrets:" -ForegroundColor Cyan - Write-Host "" - Write-Host " Name: AZURE_CLIENT_ID" -ForegroundColor Cyan - Write-Host " Value: $($app.AppId)" -ForegroundColor Cyan - Write-Host " Name: AZURE_TENANT_ID" -ForegroundColor Cyan - Write-Host " Value: $((Get-AzContext).Tenant.Id)" -ForegroundColor Cyan - Write-Host "" - Write-Host "See https://maester.dev/docs/monitoring/github#add-entra-tenant-info-to-github-repos for details." -ForegroundColor Yellow - Write-Host "" + + $secretsConfigured = $false + if ($SetGitHubSecrets) { + $secretsConfigured = Set-MtGitHubActionsSecret -GitHubRepository "$GitHubOrganization/$GitHubRepository" -ClientId $app.AppId -TenantId $tenantId + } + + if (-not $secretsConfigured) { + $manualParams = @{ + GitHubOrganization = $GitHubOrganization + GitHubRepository = $GitHubRepository + ClientId = $app.AppId + TenantId = $tenantId + } + if ($SetGitHubSecrets) { $manualParams['AttemptedAutomatic'] = $true } + Write-MtGitHubSecretsManualInstruction @manualParams + } return $createdCredential diff --git a/powershell/public/core/New-MtMaesterApp.ps1 b/powershell/public/core/New-MtMaesterApp.ps1 index a0cd8c651..fd7e80e30 100644 --- a/powershell/public/core/New-MtMaesterApp.ps1 +++ b/powershell/public/core/New-MtMaesterApp.ps1 @@ -26,6 +26,28 @@ .PARAMETER Scopes Additional custom permission scopes to include beyond the default Maester scopes. + .PARAMETER GitHubOrganization + Your GitHub organization name or GitHub username (e.g. 'jasonf'). When supplied + together with -GitHubRepository the cmdlet will also create a federated identity + credential for GitHub Actions OIDC. + + .PARAMETER GitHubRepository + Your GitHub repository name where the workflow lives (e.g. 'maester-tests'). + + .PARAMETER GitHubActions + Enable end-to-end GitHub Actions setup. Creates a federated identity credential + after granting permissions, and auto-detects the GitHub organization/repository + from the local git remote ('origin') when -GitHubOrganization/-GitHubRepository + are not explicitly supplied. This is the recommended entry point for the GitHub + flow. + + .PARAMETER SetGitHubSecrets + Pushes AZURE_CLIENT_ID and AZURE_TENANT_ID to the target repository's Actions + secrets via the GitHub CLI ('gh'). Falls back to printing manual instructions + when 'gh' is unavailable or not authenticated. Passing -SetGitHubSecrets on its + own implicitly enables the GitHub Actions flow, so -GitHubActions does not need + to be specified alongside it. + .EXAMPLE New-MtMaesterApp @@ -41,6 +63,13 @@ Creates a new Maester app with privileged scopes and additional custom scopes. + .EXAMPLE + New-MtMaesterApp -GitHubActions -SetGitHubSecrets + + Full zero-config GitHub Actions setup. Auto-detects the target repository from + the current git remote, creates the app, grants permissions, adds the federated + credential, and pushes the AZURE_CLIENT_ID / AZURE_TENANT_ID secrets via gh CLI. + .LINK https://maester.dev/docs/commands/New-MtMaesterApp #> @@ -68,7 +97,16 @@ [string] $GitHubOrganization, # Your GitHub repository name where the GitHub Actions workflow is located. E.g. maester-tests - [string] $GitHubRepository + [string] $GitHubRepository, + + # Enable end-to-end GitHub Actions setup (creates a federated identity credential). + # Auto-detects -GitHubOrganization/-GitHubRepository from the local git remote when + # they are not explicitly provided. + [switch] $GitHubActions, + + # Together with -GitHubActions, push AZURE_CLIENT_ID/AZURE_TENANT_ID to the repo's + # Actions secrets via the GitHub CLI ('gh'). + [switch] $SetGitHubSecrets ) # We use the Azure module to create the app registration since it has pre-consented permissions to create apps @@ -79,9 +117,27 @@ return } - if ($GitHubOrganization -or $GitHubRepository) { + # Treat any GitHub-flow parameter as opting into the GitHub Actions path. + $useGitHubFlow = $GitHubActions -or $SetGitHubSecrets -or $GitHubOrganization -or $GitHubRepository + + if ($useGitHubFlow) { + # Auto-detect from local git remote only when BOTH are omitted. Mixing an explicit + # value with auto-detection of the other is ambiguous (which repo did the caller + # really mean?) so we require both-or-neither. + if (-not $GitHubOrganization -and -not $GitHubRepository) { + $detected = Get-MtGitHubRepoFromGit + if ($detected) { + $GitHubOrganization = $detected.Organization + $GitHubRepository = $detected.Repository + Write-Host "Auto-detected GitHub repository from git remote: $GitHubOrganization/$GitHubRepository" -ForegroundColor Cyan + } + } elseif (-not $GitHubOrganization -or -not $GitHubRepository) { + Write-Error "Specify both -GitHubOrganization and -GitHubRepository, or omit both to auto-detect from the local git remote." + return + } + if (-not $GitHubOrganization -or -not $GitHubRepository) { - Write-Error "Both GitHubOrganization and GitHubRepository must be specified to add a federated credential." + Write-Error "Both GitHubOrganization and GitHubRepository must be specified to add a federated credential. They can be auto-detected when the current directory is a git working tree whose 'origin' remote points at GitHub." return } } @@ -171,8 +227,14 @@ Write-Host " Ensure the account running New-MtMaesterApp has Privileged Role Administrator or Global Administrator rights." -ForegroundColor Yellow } - if ($GitHubOrganization) { - Add-MtMaesterAppFederatedCredential -AppId $app.appId -GitHubOrganization $GitHubOrganization -GitHubRepository $GitHubRepository + if ($useGitHubFlow) { + $ficParams = @{ + AppId = $app.appId + GitHubOrganization = $GitHubOrganization + GitHubRepository = $GitHubRepository + } + if ($SetGitHubSecrets) { $ficParams['SetGitHubSecrets'] = $true } + Add-MtMaesterAppFederatedCredential @ficParams } else { Write-Output $result } diff --git a/powershell/tests/functions/Get-MtGitHubRepoFromGit.Tests.ps1 b/powershell/tests/functions/Get-MtGitHubRepoFromGit.Tests.ps1 new file mode 100644 index 000000000..804d92c4d --- /dev/null +++ b/powershell/tests/functions/Get-MtGitHubRepoFromGit.Tests.ps1 @@ -0,0 +1,117 @@ +Describe 'Get-MtGitHubRepoFromGit' { + BeforeAll { + Import-Module $PSScriptRoot/../../Maester.psd1 -Force + } + + Context 'When git is not available' { + It 'Returns $null when git command is not on PATH' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'git' } + Get-MtGitHubRepoFromGit | Should -BeNullOrEmpty + } + } + } + + Context 'When git remote is configured' { + It 'Parses an HTTPS GitHub URL with .git suffix' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'https://github.com/maester365/maester.git' } + + $result = Get-MtGitHubRepoFromGit + $result | Should -Not -BeNullOrEmpty + $result.Organization | Should -Be 'maester365' + $result.Repository | Should -Be 'maester' + } + } + + It 'Parses an HTTPS GitHub URL without .git suffix' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'https://github.com/contoso/security-tests' } + + $result = Get-MtGitHubRepoFromGit + $result.Organization | Should -Be 'contoso' + $result.Repository | Should -Be 'security-tests' + } + } + + It 'Parses an SSH (scp-style) GitHub URL' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'git@github.com:fabrikam/maester-tests.git' } + + $result = Get-MtGitHubRepoFromGit + $result.Organization | Should -Be 'fabrikam' + $result.Repository | Should -Be 'maester-tests' + } + } + + It 'Parses an ssh:// GitHub URL' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'ssh://git@github.com/contoso/repo.git' } + + $result = Get-MtGitHubRepoFromGit + $result.Organization | Should -Be 'contoso' + $result.Repository | Should -Be 'repo' + } + } + + It 'Returns $null for non-GitHub remotes' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'https://dev.azure.com/contoso/_git/repo' } + + Get-MtGitHubRepoFromGit | Should -BeNullOrEmpty + } + } + + It 'Returns $null when git remote returns nothing' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { '' } + + Get-MtGitHubRepoFromGit | Should -BeNullOrEmpty + } + } + + It 'Returns $null for a lookalike host that ends with github.com (e.g. evilgithub.com)' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'https://evilgithub.com/owner/repo.git' } + + Get-MtGitHubRepoFromGit | Should -BeNullOrEmpty + } + } + + It 'Returns $null for a host that has github.com as a subdomain prefix (e.g. github.com.attacker.com)' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'https://github.com.attacker.com/owner/repo.git' } + + Get-MtGitHubRepoFromGit | Should -BeNullOrEmpty + } + } + + It 'Returns $null for a hyphenated lookalike (e.g. my-github.com)' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'https://my-github.com/owner/repo.git' } + + Get-MtGitHubRepoFromGit | Should -BeNullOrEmpty + } + } + + It 'Parses an HTTPS GitHub URL with www. prefix' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'git' } } -ParameterFilter { $Name -eq 'git' } + Mock git { 'https://www.github.com/contoso/repo.git' } + + $result = Get-MtGitHubRepoFromGit + $result.Organization | Should -Be 'contoso' + $result.Repository | Should -Be 'repo' + } + } + } +} diff --git a/powershell/tests/functions/Set-MtGitHubActionsSecret.Tests.ps1 b/powershell/tests/functions/Set-MtGitHubActionsSecret.Tests.ps1 new file mode 100644 index 000000000..4d954cb83 --- /dev/null +++ b/powershell/tests/functions/Set-MtGitHubActionsSecret.Tests.ps1 @@ -0,0 +1,63 @@ +Describe 'Set-MtGitHubActionsSecret' { + BeforeAll { + Import-Module $PSScriptRoot/../../Maester.psd1 -Force + } + + Context 'When the GitHub CLI is unavailable' { + It 'Returns $false when gh is not on PATH' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'gh' } + + $result = Set-MtGitHubActionsSecret -GitHubRepository 'contoso/repo' -ClientId 'cid' -TenantId 'tid' -WarningAction SilentlyContinue + $result | Should -Be $false + } + } + } + + Context 'When gh is installed but not authenticated' { + It 'Returns $false when gh auth status exits non-zero' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'gh' } } -ParameterFilter { $Name -eq 'gh' } + Mock gh { $global:LASTEXITCODE = 1; 'not logged in' } + + $result = Set-MtGitHubActionsSecret -GitHubRepository 'contoso/repo' -ClientId 'cid' -TenantId 'tid' -WarningAction SilentlyContinue + $result | Should -Be $false + } + } + } + + Context 'When gh secret set succeeds for both secrets' { + It 'Returns $true' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'gh' } } -ParameterFilter { $Name -eq 'gh' } + # First call (`gh auth status`) and subsequent (`gh secret set`) all exit 0. + Mock gh { $global:LASTEXITCODE = 0; 'ok' } + + $result = Set-MtGitHubActionsSecret -GitHubRepository 'contoso/repo' -ClientId 'cid' -TenantId 'tid' 6>$null + $result | Should -Be $true + } + } + } + + Context 'When a gh secret set call fails' { + It 'Returns $false when the underlying gh call exits non-zero' { + InModuleScope -ModuleName 'Maester' { + Mock Get-Command { @{ Name = 'gh' } } -ParameterFilter { $Name -eq 'gh' } + # auth status passes, then the first `gh secret set` fails. + $script:ghCallCount = 0 + Mock gh { + $script:ghCallCount++ + if ($script:ghCallCount -eq 1) { + $global:LASTEXITCODE = 0 # gh auth status + return 'logged in' + } + $global:LASTEXITCODE = 1 # gh secret set + return 'permission denied' + } + + $result = Set-MtGitHubActionsSecret -GitHubRepository 'contoso/repo' -ClientId 'cid' -TenantId 'tid' -WarningAction SilentlyContinue 6>$null + $result | Should -Be $false + } + } + } +} diff --git a/website/docs/commands/Add-MtMaesterAppFederatedCredential.mdx b/website/docs/commands/Add-MtMaesterAppFederatedCredential.mdx index c7297e498..36d01780b 100644 --- a/website/docs/commands/Add-MtMaesterAppFederatedCredential.mdx +++ b/website/docs/commands/Add-MtMaesterAppFederatedCredential.mdx @@ -1,6 +1,6 @@ --- sidebar_class_name: hidden -description: Adds a federated credential to a Maester application for GitHub Actions authentication. +description: "Adds a federated credential to a Maester application for GitHub Actions authentication." id: Add-MtMaesterAppFederatedCredential title: Add-MtMaesterAppFederatedCredential hide_title: false @@ -17,15 +17,17 @@ Adds a federated credential to a Maester application for GitHub Actions authenti ### ById ```powershell -Add-MtMaesterAppFederatedCredential -Id -GitHubOrganization -GitHubRepository - [-GitHubBranch ] [-Name ] [-ProgressAction ] [] +Add-MtMaesterAppFederatedCredential -Id [-GitHubOrganization ] [-GitHubRepository ] + [-GitHubBranch ] [-Name ] [-SetGitHubSecrets] [-ProgressAction ] + [] ``` ### ByApplicationId ```powershell -Add-MtMaesterAppFederatedCredential -AppId -GitHubOrganization -GitHubRepository - [-GitHubBranch ] [-Name ] [-ProgressAction ] [] +Add-MtMaesterAppFederatedCredential -AppId [-GitHubOrganization ] [-GitHubRepository ] + [-GitHubBranch ] [-Name ] [-SetGitHubSecrets] [-ProgressAction ] + [] ``` ## DESCRIPTION @@ -53,6 +55,16 @@ Add-MtMaesterAppFederatedCredential -Id "87654321-4321-4321-4321-210987654321" - Adds a federated credential for the develop branch with a custom name. +### EXAMPLE 3 + +```powershell +Add-MtMaesterAppFederatedCredential -AppId "12345678-1234-1234-1234-123456789012" -SetGitHubSecrets +``` + +Auto-detects the GitHub organization and repository from the current git remote, adds +the federated credential, and pushes AZURE_CLIENT_ID / AZURE_TENANT_ID to the repo's +Actions secrets via the GitHub CLI. + ## PARAMETERS ### -Id @@ -91,14 +103,16 @@ Accept wildcard characters: False Your GitHub organization name or GitHub username. E.g. -jasonf +jasonf. +If omitted (together with -GitHubRepository) the value is auto-detected from +the local git remote ('origin') when the current directory is a git repo. ```yaml Type: String Parameter Sets: (All) Aliases: -Required: True +Required: False Position: Named Default value: None Accept pipeline input: False @@ -107,14 +121,22 @@ Accept wildcard characters: False ### -GitHubRepository -The GitHub repository in the format 'owner/repo' (e.g., 'myorg/myrepo'). +The GitHub repository name (without the organization). +E.g. +maester-tests. +If both -GitHubOrganization and -GitHubRepository are omitted and the current working +directory is inside a git repository whose 'origin' remote points at GitHub, both +values are auto-detected from `git remote get-url origin`. +Specifying one without +the other is not supported - either pass both explicitly, or pass neither and rely +on auto-detection. ```yaml Type: String Parameter Sets: (All) Aliases: -Required: True +Required: False Position: Named Default value: None Accept pipeline input: False @@ -141,7 +163,7 @@ Accept wildcard characters: False ### -Name The name for the federated credential. -Defaults to 'maester-devops'. +Defaults to 'maester-devops-<org>-<repo>'. ```yaml Type: String @@ -155,6 +177,31 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -SetGitHubSecrets + +If specified, sets the AZURE_CLIENT_ID and AZURE_TENANT_ID secrets on the target +GitHub repository using the GitHub CLI (`gh`). +Requires `gh` to be installed and +authenticated (`gh auth login`). +When the secrets cannot be set automatically the +cmdlet falls back to printing the manual setup instructions. + +Re-running the cmdlet with -SetGitHubSecrets against an app that already has a +matching federated credential will skip the credential creation step and proceed +directly to (re)setting the secrets. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProgressAction \{\{ Fill ProgressAction Description \}\} diff --git a/website/docs/commands/New-MtMaesterApp.mdx b/website/docs/commands/New-MtMaesterApp.mdx index d93702012..d93549429 100644 --- a/website/docs/commands/New-MtMaesterApp.mdx +++ b/website/docs/commands/New-MtMaesterApp.mdx @@ -1,6 +1,6 @@ --- sidebar_class_name: hidden -description: Creates a new Maester application in Entra ID with required permissions. +description: "Creates a new Maester application in Entra ID with required permissions." id: New-MtMaesterApp title: New-MtMaesterApp hide_title: false @@ -16,8 +16,8 @@ Creates a new Maester application in Entra ID with required permissions. ```powershell New-MtMaesterApp [[-Name] ] [-SendMail] [-SendTeamsMessage] [-Privileged] [[-Scopes] ] - [[-GitHubOrganization] ] [[-GitHubRepository] ] [-ProgressAction ] - [] + [[-GitHubOrganization] ] [[-GitHubRepository] ] [-GitHubActions] [-SetGitHubSecrets] + [-ProgressAction ] [] ``` ## DESCRIPTION @@ -56,6 +56,17 @@ New-MtMaesterApp -Privileged -Scopes @("User.Read.All", "Group.Read.All") Creates a new Maester app with privileged scopes and additional custom scopes. +### EXAMPLE 4 + +```powershell +New-MtMaesterApp -GitHubActions -SetGitHubSecrets +``` + +Full zero-config GitHub Actions setup. +Auto-detects the target repository from +the current git remote, creates the app, grants permissions, adds the federated +credential, and pushes the AZURE_CLIENT_ID / AZURE_TENANT_ID secrets via gh CLI. + ## PARAMETERS ### -Name @@ -141,10 +152,11 @@ Accept wildcard characters: False ### -GitHubOrganization -If specified adds federated credential for GitHub Actions -Your GitHub organization name or GitHub username. -E.g. -jasonf +Your GitHub organization name or GitHub username (e.g. +'jasonf'). +When supplied +together with -GitHubRepository the cmdlet will also create a federated identity +credential for GitHub Actions OIDC. ```yaml Type: String @@ -160,9 +172,8 @@ Accept wildcard characters: False ### -GitHubRepository -Your GitHub repository name where the GitHub Actions workflow is located. -E.g. -maester-tests +Your GitHub repository name where the workflow lives (e.g. +'maester-tests'). ```yaml Type: String @@ -176,6 +187,50 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -GitHubActions + +Enable end-to-end GitHub Actions setup. +Creates a federated identity credential +after granting permissions, and auto-detects the GitHub organization/repository +from the local git remote ('origin') when -GitHubOrganization/-GitHubRepository +are not explicitly supplied. +This is the recommended entry point for the GitHub +flow. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SetGitHubSecrets + +Pushes AZURE_CLIENT_ID and AZURE_TENANT_ID to the target repository's Actions +secrets via the GitHub CLI ('gh'). +Falls back to printing manual instructions +when 'gh' is unavailable or not authenticated. +Passing -SetGitHubSecrets on its +own implicitly enables the GitHub Actions flow, so -GitHubActions does not need +to be specified alongside it. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProgressAction \{\{ Fill ProgressAction Description \}\} diff --git a/website/docs/monitoring/github.md b/website/docs/monitoring/github.md index a7ee6b9cd..207edd14f 100644 --- a/website/docs/monitoring/github.md +++ b/website/docs/monitoring/github.md @@ -47,6 +47,21 @@ If you’re unable to use more advanced options like certificates stored in Azur This guide is based on [Use GitHub Actions to connect to Azure](https://learn.microsoft.com/azure/developer/github/connect-from-azure) and uses the maester GitHub action. +:::tip PowerShell shortcut + +If you have the `Maester` PowerShell module and the [GitHub CLI](https://cli.github.com/) installed, the next four sections (create Entra app, grant permissions, add federated credentials, set GitHub secrets) can be completed in a single command from inside your `maester-tests` clone: + +```powershell +Connect-Maester -Service Azure +New-MtMaesterApp -GitHubActions -SetGitHubSecrets +``` + +The cmdlet auto-detects the GitHub repository from the local `git remote`, creates the application, grants all required Graph permissions, adds the federated credential, and pushes `AZURE_CLIENT_ID` / `AZURE_TENANT_ID` to the repo's Actions secrets via `gh`. + +The portal-based steps below remain fully supported. + +::: + ### Pre-requisites Workload identity federation