public/Get-MsecAzureDevOpsSecureFile.ps1
|
function Get-MsecAzureDevOpsSecureFile { <# .SYNOPSIS Secure files stored in an Azure DevOps organization - certificates, keystores and signing material - and which pipelines may use them. .DESCRIPTION A secure file is a file a pipeline needs but nobody wants in source control: a signing certificate, a keystore, a provisioning profile, a private key. Azure DevOps stores it encrypted and hands it to authorised pipelines at run time. THE CONTENTS ARE NEVER FETCHED. There is a download endpoint and this command does not call it - the point is an inventory of what exists and who can reach it, and a report that downloads private keys to produce that inventory would be worse than no report. THE FILE NAME IS THE ONLY CLUE TO WHAT IT HOLDS, so Kind is derived from the extension and is a guess, clearly labelled as one. A .pfx is a certificate and probably carries a private key; a .key could be anything. The name is always returned so the guess can be checked. AGE MATTERS MORE HERE THAN ELSEWHERE. Signing certificates expire, and a secure file uploaded four years ago that no pipeline has been authorised against since is either expired or forgotten. Neither is visible from the file itself. .PARAMETER Organization Azure DevOps organization name: the path segment after dev.azure.com/. .PARAMETER Project Restrict to one project. All projects by default. .EXAMPLE Connect-Msec -KeyVaultName kv-msec Get-MsecAzureDevOpsSecureFile -Organization 'contoso' .EXAMPLE # Certificates and keystores any pipeline could use. Get-MsecAzureDevOpsSecureFile -Organization 'contoso' | Where-Object { $_.Kind -eq 'Certificate' -and $_.OpenToAllPipelines } .OUTPUTS PSCustomObject per secure file, PSTypeName 'MsecAzureDevOpsSecureFile'. .NOTES Needs Connect-Msec and organization membership. One call per project, plus one per file for the pipeline authorization. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0)] [string] $Organization, [string] $Project ) 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) { $files = @() try { $files = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization ` -HostName 'dev.azure.com' -ApiVersion '7.1-preview.1' ` -Path "$($proj.id)/_apis/distributedtask/securefiles") } catch { $unreadable.Add($proj.name) Write-Verbose "Could not read secure files in '$($proj.name)': $($_.Exception.Message)" continue } foreach ($file in $files) { # A GUESS from the extension, and labelled as one in the help. The name is returned # alongside so it can be checked rather than trusted. $kind = switch -Regex ([string] $file.name) { '\.(pfx|p12|cer|crt|pem)$' { 'Certificate'; break } '\.(jks|keystore|bks)$' { 'Keystore'; break } '\.(mobileprovision|provisionprofile)$' { 'ProvisioningProfile'; break } '\.(ppk|key|pk8)$' { 'Key'; break } default { 'Other' } } $open = $null; $authorized = $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/securefile/$($file.id)" $open = [bool] $perms.allPipelines.authorized $authorized = @($perms.pipelines).Count $openedBy = $perms.allPipelines.authorizedBy.displayName $openedOn = $perms.allPipelines.authorizedOn } catch { Write-Verbose "Could not read pipeline permissions for '$($file.name)': $($_.Exception.Message)" } [PSCustomObject]@{ PSTypeName = 'MsecAzureDevOpsSecureFile' Organization = $Organization Project = $proj.name Name = $file.name Kind = $kind # Signing material expires, and a file nothing has been authorised against for # years is either expired or forgotten. AgeDays = if ($file.createdOn) { [int] ([datetime]::UtcNow - [datetime] $file.createdOn).TotalDays } else { $null } OpenToAllPipelines = $open AuthorizedPipelineCount = $authorized OpenedBy = $openedBy OpenedOn = $openedOn CreatedBy = $file.createdBy.displayName CreatedOn = $file.createdOn ModifiedOn = $file.modifiedOn Id = $file.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 secure files: $shown$more. Those are NOT in this output - treat them as unread, not as projects storing none." } } |