From 77d58059acd56b5174cb83f7f7eb3bbad3e03776 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 26 May 2020 18:18:12 -0700 Subject: [PATCH] First attempt at GitHub Secrets --- GitHubSecrets.ps1 | 493 ++++++++++++++++++++++++++++++++++ PowerShellForGitHub.psd1 | 9 + Tests/GitHubSecrets.Tests.ps1 | 26 ++ 3 files changed, 528 insertions(+) create mode 100644 GitHubSecrets.ps1 create mode 100644 Tests/GitHubSecrets.Tests.ps1 diff --git a/GitHubSecrets.ps1 b/GitHubSecrets.ps1 new file mode 100644 index 00000000..5ddeb514 --- /dev/null +++ b/GitHubSecrets.ps1 @@ -0,0 +1,493 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +function Get-GitHubRepositoryPublicKey { +<# + .SYNOPSIS + Gets the public key for a given repository, which is needed to encrypt secrets. + + .DESCRIPTION + Gets the public key for a given repository, which is needed to encrypt secrets before creating or updating. + Anyone with read access to the repository can use this cmdlet. + If the repository is private you must use an access token with the repo scope. + GitHub Apps must have the secrets repository permission to use this cmdlet. + + .PARAMETER OwnerName + Owner of the repository. + If not supplied here, the DefaultOwnerName configuration property value will be used. + + .PARAMETER RepositoryName + Name of the repository. + If not supplied here, the DefaultRepositoryName configuration property value will be used. + + .PARAMETER Uri + Uri for the repository. + The OwnerName and RepositoryName will be extracted from here instead of needing to provide + them individually. + + .PARAMETER AccessToken + If provided, this will be used as the AccessToken for authentication with the + REST Api. Otherwise, will attempt to use the configured value or will run unauthenticated. + + .PARAMETER NoStatus + If this switch is specified, long-running commands will run on the main thread + with no commandline status update. When not specified, those commands run in + the background, enabling the command prompt to provide status information. + If not supplied here, the DefaultNoStatus configuration property value will be used. + + .EXAMPLE + Get-GitHubRepositoryPublicKey -OwnerName Microsoft -RepositoryName PowerShellForGitHub + + .EXAMPLE + Get-GitHubRepositoryPublicKey -Uri 'https://github.com/Microsoft/PowerShellForGitHub' +#> + [CmdletBinding( + SupportsShouldProcess, + DefaultParameterSetName='Elements')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] + param( + [Parameter(ParameterSetName='Elements')] + [string] $OwnerName, + + [Parameter(ParameterSetName='Elements')] + [string] $RepositoryName, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [string] $Uri, + + [string] $AccessToken, + + [switch] $NoStatus + ) + + Write-InvocationLog + + $elements = Resolve-RepositoryElements + $OwnerName = $elements.ownerName + $RepositoryName = $elements.repositoryName + + $telemetryProperties = @{ + 'OwnerName' = (Get-PiiSafeString -PlainText $OwnerName) + 'RepositoryName' = (Get-PiiSafeString -PlainText $RepositoryName) + } + + $params = @{ + 'UriFragment' = "/repos/$OwnerName/$RepositoryName/actions/secrets/public-key" + 'Description' = "Getting public key for $RepositoryName." + 'AcceptHeader' = 'application/vnd.github.symmetra-preview+json' + 'AccessToken' = $AccessToken + 'TelemetryEventName' = $MyInvocation.MyCommand.Name + 'TelemetryProperties' = $telemetryProperties + 'NoStatus' = (Resolve-ParameterWithDefaultConfigurationValue -Name NoStatus -ConfigValueName DefaultNoStatus) + } + + return Invoke-GHRestMethodMultipleResult @params +} + +function Get-GitHubSecretInfo { +<# + .SYNOPSIS + Lists all secrets or gets a particular secret available in a repository without revealing their encrypted values. + + .DESCRIPTION + Lists all secrets or gets a particular secret available in a repository without revealing their encrypted values. + You must authenticate using an access token with the repo scope to use this cmdlet. + GitHub Apps must have the secrets repository permission to use this cmdlet. + + .PARAMETER OwnerName + Owner of the repository. + If not supplied here, the DefaultOwnerName configuration property value will be used. + + .PARAMETER RepositoryName + Name of the repository. + If not supplied here, the DefaultRepositoryName configuration property value will be used. + + .PARAMETER Uri + Uri for the repository. + The OwnerName and RepositoryName will be extracted from here instead of needing to provide + them individually. + + .PARAMETER Name + Name of the secret. + If not provided, it will retrieve all secrets in a repository. + + .PARAMETER AccessToken + If provided, this will be used as the AccessToken for authentication with the + REST Api. Otherwise, will attempt to use the configured value or will run unauthenticated. + + .PARAMETER NoStatus + If this switch is specified, long-running commands will run on the main thread + with no commandline status update. When not specified, those commands run in + the background, enabling the command prompt to provide status information. + If not supplied here, the DefaultNoStatus configuration property value will be used. + + .EXAMPLE + Get-GitHubSecretInfo -OwnerName Microsoft -RepositoryName PowerShellForGitHub + + .EXAMPLE + Get-GitHubSecretInfo -Uri 'https://github.com/Microsoft/PowerShellForGitHub' + + .EXAMPLE + Get-GitHubSecretInfo -OwnerName Microsoft -RepositoryName PowerShellForGitHub -SecretName MySecret +#> + [CmdletBinding( + SupportsShouldProcess, + DefaultParameterSetName='Elements')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] + param( + [Parameter(ParameterSetName='Elements')] + [string] $OwnerName, + + [Parameter(ParameterSetName='Elements')] + [string] $RepositoryName, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [string] $Uri, + + [string] $SecretName, + + [string] $AccessToken, + + [switch] $NoStatus + ) + + if ([WildcardPattern]::ContainsWildcardCharacters($SecretName)) + { + throw "The Name parameter cannot contain wild card characters." + } + + Write-InvocationLog + + $elements = Resolve-RepositoryElements + $OwnerName = $elements.ownerName + $RepositoryName = $elements.repositoryName + + $telemetryProperties = @{ + 'OwnerName' = (Get-PiiSafeString -PlainText $OwnerName) + 'RepositoryName' = (Get-PiiSafeString -PlainText $RepositoryName) + } + + if ($PSBoundParameters.ContainsKey('SecretName')) + { + $description = "Getting secret info of $SecretName for $RepositoryName" + $uriFragment = "/repos/$OwnerName/$RepositoryName/actions/secrets/$SecretName" + } + else + { + $description = "Getting secret infos for $RepositoryName" + $uriFragment = "/repos/$OwnerName/$RepositoryName/actions/secrets" + } + + $params = @{ + 'UriFragment' = $uriFragment + 'Description' = $description + 'AcceptHeader' = 'application/vnd.github.symmetra-preview+json' + 'AccessToken' = $AccessToken + 'TelemetryEventName' = $MyInvocation.MyCommand.Name + 'TelemetryProperties' = $telemetryProperties + 'NoStatus' = (Resolve-ParameterWithDefaultConfigurationValue -Name NoStatus -ConfigValueName DefaultNoStatus) + } + + try { + $result = Invoke-GHRestMethodMultipleResult @params + } catch { + $message = $_.ErrorDetails.Message + if ($message -notmatch 'Not Found') { + throw $_ + } + } + + if($PSBoundParameters.ContainsKey('SecretName')) { + $result + } else { + $result.secrets + } +} + +function Set-GitHubSecret { +<# + .SYNOPSIS + Creates or updates a repository secret with a value. + + .DESCRIPTION + Creates or updates a repository secret with a value. The value is encrypted using PSSodium which + is simple wrapper around Sodium.Core. + + .PARAMETER OwnerName + Owner of the repository. + If not supplied here, the DefaultOwnerName configuration property value will be used. + + .PARAMETER RepositoryName + Name of the repository. + If not supplied here, the DefaultRepositoryName configuration property value will be used. + + .PARAMETER Uri + Uri for the repository. + The OwnerName and RepositoryName will be extracted from here instead of needing to provide + them individually. + + .PARAMETER Name + Name of the secret. + If not provided, it will retrieve all secrets in a repository. + + .PARAMETER Value + Value for the secret. + If not provided, it will retrieve all secrets in a repository. + + .PARAMETER AccessToken + If provided, this will be used as the AccessToken for authentication with the + REST Api. Otherwise, will attempt to use the configured value or will run unauthenticated. + + .PARAMETER NoStatus + If this switch is specified, long-running commands will run on the main thread + with no commandline status update. When not specified, those commands run in + the background, enabling the command prompt to provide status information. + If not supplied here, the DefaultNoStatus configuration property value will be used. + + .EXAMPLE + Set-GitHubSecret -OwnerName Microsoft -RepositoryName PowerShellForGitHub -SecretName MySecret -SecretValue 'my text' + + .EXAMPLE + Set-GitHubSecret -Uri 'https://github.com/Microsoft/PowerShellForGitHub' -SecretName MySecret -SecretValue 'my text' +#> + [CmdletBinding( + SupportsShouldProcess, + DefaultParameterSetName='Elements')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] + param( + [Parameter(ParameterSetName='Elements')] + [string] $OwnerName, + + [Parameter(ParameterSetName='Elements')] + [string] $RepositoryName, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [string] $Uri, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [Parameter(Mandatory, ParameterSetName='Elements')] + [string] $SecretName, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [Parameter(Mandatory, ParameterSetName='Elements')] + [SecureString] $SecretValue, + + [string] $AccessToken, + + [switch] $NoStatus + ) + + Write-InvocationLog + + $elements = Resolve-RepositoryElements + $OwnerName = $elements.ownerName + $RepositoryName = $elements.repositoryName + + $publicKeyInfo = Get-GitHubRepositoryPublicKey -OwnerName $OwnerName -RepositoryName $RepositoryName -NoStatus + + $hashBody = @{ + encrypted_value = ConvertTo-SodiumEncryptedString -Text $SecretValue -PublicKey $publicKeyInfo.key + key_id = $publicKeyInfo.key_id + } + + $telemetryProperties = @{ + 'OwnerName' = (Get-PiiSafeString -PlainText $OwnerName) + 'RepositoryName' = (Get-PiiSafeString -PlainText $RepositoryName) + } + + $description = "Setting secret of $SecretName for $RepositoryName" + $uriFragment = "/repos/$OwnerName/$RepositoryName/actions/secrets/$SecretName" + + $params = @{ + 'UriFragment' = $uriFragment + 'Description' = $description + 'Body' = (ConvertTo-Json -InputObject $hashBody) + 'Method' = 'Put' + 'AcceptHeader' = 'application/vnd.github.symmetra-preview+json' + 'AccessToken' = $AccessToken + 'TelemetryEventName' = $MyInvocation.MyCommand.Name + 'TelemetryProperties' = $telemetryProperties + 'NoStatus' = (Resolve-ParameterWithDefaultConfigurationValue -Name NoStatus -ConfigValueName DefaultNoStatus) + } + + Invoke-GHRestMethod @params +} + +function New-GitHubSecret { +<# + .SYNOPSIS + Creates a repository secret with a value. Throws if the secret already exists. + + .DESCRIPTION + Creates a repository secret with a value. Throws if the secret already exists. + The value is encrypted using PSSodium which is simple wrapper around Sodium.Core. + + .PARAMETER OwnerName + Owner of the repository. + If not supplied here, the DefaultOwnerName configuration property value will be used. + + .PARAMETER RepositoryName + Name of the repository. + If not supplied here, the DefaultRepositoryName configuration property value will be used. + + .PARAMETER Uri + Uri for the repository. + The OwnerName and RepositoryName will be extracted from here instead of needing to provide + them individually. + + .PARAMETER Name + Name of the secret. + If not provided, it will retrieve all secrets in a repository. + + .PARAMETER Value + Value for the secret. + If not provided, it will retrieve all secrets in a repository. + + .PARAMETER AccessToken + If provided, this will be used as the AccessToken for authentication with the + REST Api. Otherwise, will attempt to use the configured value or will run unauthenticated. + + .PARAMETER NoStatus + If this switch is specified, long-running commands will run on the main thread + with no commandline status update. When not specified, those commands run in + the background, enabling the command prompt to provide status information. + If not supplied here, the DefaultNoStatus configuration property value will be used. + + .EXAMPLE + New-GitHubSecret -OwnerName Microsoft -RepositoryName PowerShellForGitHub -SecretName MySecret -SecretValue 'my text' + + .EXAMPLE + New-GitHubSecret -Uri 'https://github.com/Microsoft/PowerShellForGitHub' -SecretName MySecret -SecretValue 'my text' +#> + [CmdletBinding( + SupportsShouldProcess, + DefaultParameterSetName='Elements')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] + param( + [Parameter(ParameterSetName='Elements')] + [string] $OwnerName, + + [Parameter(ParameterSetName='Elements')] + [string] $RepositoryName, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [string] $Uri, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [Parameter(Mandatory, ParameterSetName='Elements')] + [string] $SecretName, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [Parameter(Mandatory, ParameterSetName='Elements')] + [SecureString] $SecretValue, + + [string] $AccessToken, + + [switch] $NoStatus + ) + + Write-InvocationLog + + $elements = Resolve-RepositoryElements + $OwnerName = $elements.ownerName + $RepositoryName = $elements.repositoryName + + if(Get-GitHubSecretInfo -OwnerName $OwnerName -RepositoryName $RepositoryName -SecretName $SecretName -ErrorAction Ignore) { + throw "Secret already exists." + } + + Set-GitHubSecret @PSBoundParameters +} + +function Remove-GitHubSecret { +<# + .SYNOPSIS + Removes a repository secret with a value. + + .DESCRIPTION + Removes a repository secret with a value. The value is encrypted using PSSodium which + is simple wrapper around Sodium.Core. + + .PARAMETER OwnerName + Owner of the repository. + If not supplied here, the DefaultOwnerName configuration property value will be used. + + .PARAMETER RepositoryName + Name of the repository. + If not supplied here, the DefaultRepositoryName configuration property value will be used. + + .PARAMETER Uri + Uri for the repository. + The OwnerName and RepositoryName will be extracted from here instead of needing to provide + them individually. + + .PARAMETER Name + Name of the secret. + If not provided, it will retrieve all secrets in a repository. + + .PARAMETER AccessToken + If provided, this will be used as the AccessToken for authentication with the + REST Api. Otherwise, will attempt to use the configured value or will run unauthenticated. + + .PARAMETER NoStatus + If this switch is specified, long-running commands will run on the main thread + with no commandline status update. When not specified, those commands run in + the background, enabling the command prompt to provide status information. + If not supplied here, the DefaultNoStatus configuration property value will be used. + + .EXAMPLE + Set-GitHubSecret -OwnerName Microsoft -RepositoryName PowerShellForGitHub -SecretName MySecret -SecretValue 'my text' + + .EXAMPLE + Set-GitHubSecret -Uri 'https://github.com/Microsoft/PowerShellForGitHub' -SecretName MySecret -SecretValue 'my text' +#> + [CmdletBinding( + SupportsShouldProcess, + DefaultParameterSetName='Elements')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="Methods called within here make use of PSShouldProcess, and the switch is passed on to them inherently.")] + param( + [Parameter(ParameterSetName='Elements')] + [string] $OwnerName, + + [Parameter(ParameterSetName='Elements')] + [string] $RepositoryName, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [string] $Uri, + + [Parameter(Mandatory, ParameterSetName='Uri')] + [Parameter(Mandatory, ParameterSetName='Elements')] + [string] $SecretName, + + [string] $AccessToken, + + [switch] $NoStatus + ) + + Write-InvocationLog + + $elements = Resolve-RepositoryElements + $OwnerName = $elements.ownerName + $RepositoryName = $elements.repositoryName + + $telemetryProperties = @{ + 'OwnerName' = (Get-PiiSafeString -PlainText $OwnerName) + 'RepositoryName' = (Get-PiiSafeString -PlainText $RepositoryName) + } + + $description = "Setting secret of $SecretName for $RepositoryName" + $uriFragment = "/repos/$OwnerName/$RepositoryName/actions/secrets/$SecretName" + + $params = @{ + 'UriFragment' = $uriFragment + 'Description' = $description + 'Method' = 'Delete' + 'AcceptHeader' = 'application/vnd.github.symmetra-preview+json' + 'AccessToken' = $AccessToken + 'TelemetryEventName' = $MyInvocation.MyCommand.Name + 'TelemetryProperties' = $telemetryProperties + 'NoStatus' = (Resolve-ParameterWithDefaultConfigurationValue -Name NoStatus -ConfigValueName DefaultNoStatus) + } + + Invoke-GHRestMethod @params +} diff --git a/PowerShellForGitHub.psd1 b/PowerShellForGitHub.psd1 index 7bcec540..eaf04b5a 100644 --- a/PowerShellForGitHub.psd1 +++ b/PowerShellForGitHub.psd1 @@ -13,6 +13,9 @@ # Script module or binary module file associated with this manifest. RootModule = 'PowerShellForGitHub.psm1' + # Modules required that will be installed in addition to this one by PowerShellGet. + RequiredModules = @('PSSodium') + # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess NestedModules = @( # Ideally this list would be kept completely alphabetical, but other scripts (like @@ -42,6 +45,7 @@ 'GitHubRepositoryTraffic.ps1', 'GitHubTeams.ps1', 'GitHubUsers.ps1', + 'GitHubSecrets.ps1', 'NugetTools.ps1', 'Telemetry.ps1') @@ -83,9 +87,11 @@ 'Get-GitHubRepositoryContributor', 'Get-GitHubRepositoryFork', 'Get-GitHubRepositoryLanguage', + 'Get-GitHubRepositoryPublicKey', 'Get-GitHubRepositoryTag', 'Get-GitHubRepositoryTopic', 'Get-GitHubRepositoryUniqueContributor', + 'Get-GitHubSecretInfo', 'Get-GitHubTeam', 'Get-GitHubTeamMember', 'Get-GitHubUser', @@ -110,6 +116,7 @@ 'New-GitHubPullRequest', 'New-GitHubRepository', 'New-GitHubRepositoryFork', + 'New-GitHubSecret', 'Remove-GithubAssignee', 'Remove-GitHubComment', 'Remove-GitHubIssueLabel', @@ -120,6 +127,7 @@ 'Remove-GitHubProjectColumn', 'Remove-GitHubRepository', 'Rename-GitHubRepository', + 'Remove-GitHubSecret', 'Reset-GitHubConfiguration', 'Restore-GitHubConfiguration', 'Set-GitHubAuthentication', @@ -132,6 +140,7 @@ 'Set-GitHubProjectCard', 'Set-GitHubProjectColumn', 'Set-GitHubRepositoryTopic', + 'Set-GitHubSecret', 'Split-GitHubUri', 'Test-GitHubAssignee', 'Test-GitHubAuthenticationConfigured', diff --git a/Tests/GitHubSecrets.Tests.ps1 b/Tests/GitHubSecrets.Tests.ps1 new file mode 100644 index 00000000..f2fea4ea --- /dev/null +++ b/Tests/GitHubSecrets.Tests.ps1 @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +<# +.Synopsis + Tests for GitHubSecrets.ps1 module +#> + +# This is common test code setup logic for all Pester test files +$moduleRootPath = Split-Path -Path $PSScriptRoot -Parent +. (Join-Path -Path $moduleRootPath -ChildPath 'Tests\Common.ps1') + +try +{ + # How do we test secrets? + # Ideally _with_ GitHub Actions +} +finally +{ + if (Test-Path -Path $script:originalConfigFile -PathType Leaf) + { + # Restore the user's configuration to its pre-test state + Restore-GitHubConfiguration -Path $script:originalConfigFile + $script:originalConfigFile = $null + } +}