public/Get-MsecAzureDevOpsVariableGroup.ps1
|
function Get-MsecAzureDevOpsVariableGroup { <# .SYNOPSIS Variable groups across an Azure DevOps organization - what they hold, who they are shared with, and whether any pipeline may use them. .DESCRIPTION A variable group holds values that pipelines consume, and secret variables in it are credentials by another name. The question worth answering is not "does it contain secrets" but "which pipelines can reach them" - a group marked available to ALL pipelines in a project can be referenced by a pipeline someone writes this afternoon. THE COMBINATION IS THE FINDING. Secrets in a group, open to every pipeline, in a project whose repositories require no reviewer, means anyone who can push can author a pipeline that reads them. Each of the three is unremarkable alone. This command reports the first two; Get-MsecAzureDevOpsRepository reports the third. VALUES ARE NEVER RETURNED, AND SECRET VALUES ARE NOT AVAILABLE ANYWAY. Azure DevOps does not return secret values through this API. Non-secret values are returned by the API and are deliberately dropped here: this output goes into mailboxes and spreadsheets, and pipeline variables carry connection strings and hostnames often enough that copying them into a report is a poor default. Variable NAMES are kept, because knowing a group holds 'AZURE_CLIENT_SECRET' is the point. A KEY VAULT-BACKED GROUP IS A REFERENCE, NOT A COPY. Its type is AzureKeyVault and the secrets stay in the vault, fetched at run time through a service connection. That moves the question to the vault and the connection rather than removing it. .PARAMETER Organization Azure DevOps organization name: the path segment after dev.azure.com/. .PARAMETER Project Restrict to one project. All projects by default. .PARAMETER WithSecrets Only groups that hold at least one secret variable, or are backed by a Key Vault. .EXAMPLE Connect-Msec -KeyVaultName kv-msec Get-MsecAzureDevOpsVariableGroup -Organization 'contoso' -WithSecrets | Where-Object OpenToAllPipelines .EXAMPLE # Everything a pipeline author could reach without asking anyone. Get-MsecAzureDevOpsVariableGroup -Organization 'contoso' | Where-Object OpenToAllPipelines | Sort-Object SecretCount -Descending .OUTPUTS PSCustomObject per variable group, PSTypeName 'MsecAzureDevOpsVariableGroup'. .NOTES Needs Connect-Msec and organization membership. One call per project, plus one per group for the pipeline authorization. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0)] [string] $Organization, [string] $Project, [switch] $WithSecrets ) Assert-MsecSession $projects = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization ` -HostName 'dev.azure.com' -ApiVersion '7.1' -Path '_apis/projects' -All) if ($Project) { $projects = @($projects | Where-Object { $_.name -eq $Project }) if (-not $projects.Count) { throw "No project named '$Project' in '$Organization'." } } if (-not $projects.Count) { Write-Warning "No projects returned for '$Organization'. That is 'nothing was read', not 'no projects'." return } $unreadable = [System.Collections.Generic.List[string]]::new() foreach ($proj in $projects) { $groups = @() try { $groups = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization ` -HostName 'dev.azure.com' -ApiVersion '7.1-preview.2' ` -Path "$($proj.id)/_apis/distributedtask/variablegroups") } catch { # Named, not skipped: a project whose library could not be read is not a project # without variable groups. $unreadable.Add($proj.name) Write-Verbose "Could not read variable groups in '$($proj.name)': $($_.Exception.Message)" continue } foreach ($group in $groups) { $variables = @($group.variables.PSObject.Properties) $secrets = @($variables | Where-Object { $_.Value.isSecret }) $isKeyVault = $group.type -eq 'AzureKeyVault' if ($WithSecrets -and -not $secrets.Count -and -not $isKeyVault) { continue } $allPipelines = $null $authorizedCount = $null $openedBy = $null $openedOn = $null try { $perms = Invoke-MsecAzureDevOpsRequest -Organization $Organization ` -HostName 'dev.azure.com' -ApiVersion '7.1-preview.1' ` -Path "$($proj.id)/_apis/pipelines/pipelinePermissions/variablegroup/$($group.id)" # Omitted when the setting is off, so absence is false - but a failed call stays # $null, which is a different claim. $allPipelines = [bool] $perms.allPipelines.authorized $authorizedCount = @($perms.pipelines).Count # WHO opened it and WHEN. The decision is usually old and the person who made it # has often moved on - a group opened three years ago by someone reasoning about # a pipeline that no longer exists is the common case, and neither the count nor # the boolean says so. $openedBy = $perms.allPipelines.authorizedBy.displayName $openedOn = $perms.allPipelines.authorizedOn } catch { Write-Verbose "Could not read pipeline permissions for group '$($group.name)': $($_.Exception.Message)" } [PSCustomObject]@{ PSTypeName = 'MsecAzureDevOpsVariableGroup' Organization = $Organization Project = $proj.name Name = $group.name Type = $group.type # Where the secrets actually live, for a Key Vault-backed group. KeyVault = if ($isKeyVault) { $group.providerData.vault } else { $null } VariableCount = $variables.Count SecretCount = $secrets.Count # NAMES only - see the help on why values are dropped. SecretNames = ($secrets | ForEach-Object { $_.Name } | Sort-Object) -join ', ' VariableNames = ($variables | ForEach-Object { $_.Name } | Sort-Object) -join ', ' # TRUE IS THE PERMISSIVE STATE. Someone ticked "Grant access permission to all # pipelines", so any pipeline in the project may reference this group with no # further approval. FALSE means pipelines are authorised one at a time - the # first run prompts someone, and that pipeline then shows in # AuthorizedPipelineCount. OpenToAllPipelines = $allPipelines AuthorizedPipelineCount = $authorizedCount OpenedBy = $openedBy OpenedOn = $openedOn # Shared groups are reachable from more than the project that owns them. IsShared = [bool] $group.isShared SharedWithProjectCount = @($group.variableGroupProjectReferences).Count ModifiedBy = $group.modifiedBy.displayName ModifiedOn = $group.modifiedOn CreatedBy = $group.createdBy.displayName Id = $group.id } } } if ($unreadable.Count) { $shown = ($unreadable | Select-Object -First 5) -join ', ' $more = if ($unreadable.Count -gt 5) { " and $($unreadable.Count - 5) more" } else { '' } Write-Warning "$($unreadable.Count) project(s) refused their variable groups: $shown$more. Those groups are NOT in this output - treat them as unread, not as projects without a library." } } |