From 66026e421cedd6c01abae7ec7fa1308ffcc172a6 Mon Sep 17 00:00:00 2001 From: "Wesley Camargo (TCSNLPS)" Date: Wed, 5 Mar 2025 18:40:32 +0000 Subject: [PATCH 01/14] Update azure-pipelines.yml for Azure Pipelines --- pipelines/azure-pipelines.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pipelines/azure-pipelines.yml b/pipelines/azure-pipelines.yml index 2943d93..f0ed0c8 100644 --- a/pipelines/azure-pipelines.yml +++ b/pipelines/azure-pipelines.yml @@ -5,13 +5,13 @@ pool: variables: # Set to your variable group containing ADO_PAT - - group: 'ado-psrule-run' + - group: 'azdo-psrule-run' # Set to your Azure DevOps organization - name: devops_organization - value: 'cloudyspells' + value: 'tcsnlps' # Set to your Azure DevOps project - name: devops_project - value: 'psrule-fail-project' + value: 'ssc-set' schedules: - cron: "5 8 * * 0" @@ -47,7 +47,7 @@ stages: inputs: targetType: 'inline' script: | - Connect-AzDevOps -Organization $(devops_organization) -PAT "$(ADOPAT)" + Connect-AzDevOps -Organization $(AZDO-ORGANIZATION) -PAT "$(AZDO-PAT)" Export-AzDevOpsRuleData ` -Project $(devops_project) ` -OutputPath .\Temp From 1f41e23febf1e4e769c30f1516d5582e8f543082 Mon Sep 17 00:00:00 2001 From: "Gajendra T (TCSNLPS)" Date: Wed, 16 Apr 2025 13:47:03 +0000 Subject: [PATCH 02/14] Created additional rules for projects, repos, pipelines and users Created additional rules for projects, repos, pipelines and users Related work items: #5877, #5880, #5882, #6304, #6303 --- .gitignore | 1 + ProjectStatisticsReport.xlsx | Bin 0 -> 9908 bytes ServiceConnectionsReport.xlsx | Bin 0 -> 3955 bytes TestPlanUsageReport.csv | 6 + .../Functions/DevOps.OrganizationPolicy.ps1 | 93 +++++++++ .../PSRule.Rules.AzureDevOps.psm1 | 4 + src/helper-functions/Export-ToExcel.ps1 | 71 +++++++ .../New-AdoAuthenticationHeader.ps1 | 56 ++++++ src/reports/ArtifactStorageReport.ps1 | 148 +++++++++++++++ src/reports/AuditStreams.ps1 | 69 +++++++ src/reports/BranchPoliciesOnAllRepos.ps1 | 159 ++++++++++++++++ src/reports/BranchStructureReport.ps1 | 132 +++++++++++++ src/reports/BranchesNotMergedReport.ps1 | 170 +++++++++++++++++ src/reports/BranchesPerRepoReport.ps1 | 177 +++++++++++++++++ src/reports/InactiveUsers.ps1 | 98 ++++++++++ src/reports/InstalledExtensions.ps1 | 79 ++++++++ src/reports/NonDomainUsers.ps1 | 86 +++++++++ src/reports/ProjectAgentPools.ps1 | 124 ++++++++++++ src/reports/ProjectProcessUsageReport.ps1 | 120 ++++++++++++ src/reports/ProjectStatistics.ps1 | 178 ++++++++++++++++++ src/reports/ReposWithoutBranchingPolicies.ps1 | 138 ++++++++++++++ src/reports/ServiceConnections.ps1 | 129 +++++++++++++ src/reports/TestPlanUsageReport.ps1 | 111 +++++++++++ src/reports/readme.md | 3 + tools/private/readme.md | 7 + 25 files changed, 2159 insertions(+) create mode 100644 ProjectStatisticsReport.xlsx create mode 100644 ServiceConnectionsReport.xlsx create mode 100644 TestPlanUsageReport.csv create mode 100644 src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 create mode 100644 src/helper-functions/Export-ToExcel.ps1 create mode 100644 src/helper-functions/New-AdoAuthenticationHeader.ps1 create mode 100644 src/reports/ArtifactStorageReport.ps1 create mode 100644 src/reports/AuditStreams.ps1 create mode 100644 src/reports/BranchPoliciesOnAllRepos.ps1 create mode 100644 src/reports/BranchStructureReport.ps1 create mode 100644 src/reports/BranchesNotMergedReport.ps1 create mode 100644 src/reports/BranchesPerRepoReport.ps1 create mode 100644 src/reports/InactiveUsers.ps1 create mode 100644 src/reports/InstalledExtensions.ps1 create mode 100644 src/reports/NonDomainUsers.ps1 create mode 100644 src/reports/ProjectAgentPools.ps1 create mode 100644 src/reports/ProjectProcessUsageReport.ps1 create mode 100644 src/reports/ProjectStatistics.ps1 create mode 100644 src/reports/ReposWithoutBranchingPolicies.ps1 create mode 100644 src/reports/ServiceConnections.ps1 create mode 100644 src/reports/TestPlanUsageReport.ps1 create mode 100644 src/reports/readme.md create mode 100644 tools/private/readme.md diff --git a/.gitignore b/.gitignore index 8673983..5d9b570 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/tools/private/*.ps1 tests/out* coverage.xml myenv.ps1 diff --git a/ProjectStatisticsReport.xlsx b/ProjectStatisticsReport.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4c7b4a9ca901a038ab0380a04bdc73059d059bb0 GIT binary patch literal 9908 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 literal 0 HcmV?d00001 diff --git a/ServiceConnectionsReport.xlsx b/ServiceConnectionsReport.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..b6354e48148f1249a6a289c09c2a0e44996a73a3 GIT binary patch literal 3955 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 NgvX4~JgUQY{tsD-aWDV? literal 0 HcmV?d00001 diff --git a/TestPlanUsageReport.csv b/TestPlanUsageReport.csv new file mode 100644 index 0000000..a6ddd91 --- /dev/null +++ b/TestPlanUsageReport.csv @@ -0,0 +1,6 @@ +"ProjectName","TestPlansEnabled","NumberOfTestPlans" +"uwv-fp4","False","0" +"plt-exp","False","0" +"tnt-cop","False","0" +"plt-sep","False","0" +"DEPRICATED-uwv-fp1","False","0" diff --git a/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 b/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 new file mode 100644 index 0000000..b5e148b --- /dev/null +++ b/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 @@ -0,0 +1,93 @@ +<# + .SYNOPSIS + Get the organization's policies from Azure DevOps + + .DESCRIPTION + Get the organization's policies from Azure DevOps + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .EXAMPLE + Get-AzDevOpsOrganizationPolicy -Organization $Organization +#> + +Function Get-AzDevOpsOrganizationPolicy { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Organization + ) + if ($null -eq $script:connection) { + throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" + } + $header = $script:connection.GetHeader() + $uri = "https://dev.azure.com/$Organization/_apis/policy/configurations?api-version=7.1-preview.1" + Write-Verbose "URI: $uri" + try { + $policies = Invoke-RestMethod -Uri $uri -Method Get -Headers $header -ContentType 'application/json' + if ($policies -is [string]) { + throw "Authentication failed or policies not found" + } + } + catch { + throw $_.Exception.Message + } + return $policies +} +Export-ModuleMember -Function Get-AzDevOpsOrganizationPolicy + +<# + .SYNOPSIS + Export the organization's policies from Azure DevOps to a JSON file + + .DESCRIPTION + Export the organization's policies from Azure DevOps to a JSON file with .ado.orgpolicies.json extension + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER OutputPath + Output path for JSON files + + .PARAMETER PassThru + Return the exported policies as objects to the pipeline instead of writing to a file + + .EXAMPLE + Export-AzDevOpsOrganizationPolicies -Organization $Organization -OutputPath $OutputPath +#> +function Export-AzDevOpsOrganizationPolicies { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] + $Organization, + [Parameter(ParameterSetName = 'JsonFile')] + [string] + $OutputPath, + [Parameter(ParameterSetName = 'PassThru')] + [switch] + $PassThru + ) + if ($null -eq $script:connection) { + throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" + } + Write-Verbose "Getting organization policies from Azure DevOps" + $policies = Get-AzDevOpsOrganizationPolicy -Organization $Organization + $policies | Add-Member -MemberType NoteProperty -Name ObjectType -Value 'Azure.DevOps.Organization.Policies' + $policies | Add-Member -MemberType NoteProperty -Name ObjectName -Value ("{0}.OrganizationPolicies" -f $script:connection.Organization) + $policies | Add-Member -MemberType NoteProperty -Name Name -Value "OrganizationPolicies" + $id = @{ + originalId = $null + resourceName = 'OrganizationPolicies' + organization = $script:connection.Organization + } | ConvertTo-Json -Depth 100 + $policies | Add-Member -MemberType NoteProperty -Name id -Value $id + if ($PassThru) { + Write-Output $policies + } else { + $policies | ConvertTo-Json -Depth 10 | Out-File (Join-Path -Path $OutputPath -ChildPath "$Organization.ado.orgpolicies.json") + } +} +Export-ModuleMember -Function Export-AzDevOpsOrganizationPolicies diff --git a/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 b/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 index 8f30033..ac5a0b9 100644 --- a/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 +++ b/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 @@ -69,6 +69,8 @@ Function Export-AzDevOpsRuleData { Export-AzDevOpsReleaseDefinitions -Project $Project -PassThru Write-Verbose "Exporting groups" Export-AzDevOpsGroups -Project $Project -PassThru + # Write-Verbose "Exporting users" + # Export-AzDevOpsUsers -PassThru Write-Verbose "Exporting retention settings" Export-AzDevOpsRetentionSettings -Project $Project -PassThru } else { @@ -91,6 +93,8 @@ Function Export-AzDevOpsRuleData { Export-AzDevOpsReleaseDefinitions -Project $Project -OutputPath $OutputPath Write-Verbose "Exporting groups" Export-AzDevOpsGroups -Project $Project -OutputPath $OutputPath + # Write-Verbose "Exporting users" + # Export-AzDevOpsUsers -OutputPath $OutputPath Write-Verbose "Exporting retention settings" Export-AzDevOpsRetentionSettings -Project $Project -OutputPath $OutputPath } diff --git a/src/helper-functions/Export-ToExcel.ps1 b/src/helper-functions/Export-ToExcel.ps1 new file mode 100644 index 0000000..0dd4c95 --- /dev/null +++ b/src/helper-functions/Export-ToExcel.ps1 @@ -0,0 +1,71 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Exports a report to an Excel file with predefined formatting. + + .DESCRIPTION + Exports the provided report data to an Excel file in the C:\Temp directory with formatting options + such as autosizing columns, freezing the top row, and bolding the top row. + + .PARAMETER Report + The data to export to Excel, typically an array of PSObjects. + + .PARAMETER ExportFileName + The name of the Excel file (e.g., "Report.xlsx"). The file will be saved in C:\Temp. + + .PARAMETER WorksheetName + The name of the worksheet in the Excel file. + + .EXAMPLE + Export-ToExcel -Report $reportData -ExportFileName "Report.xlsx" -WorksheetName "ReportData" +#> + +function Export-ToExcel { + param ( + [Parameter(Mandatory)] + [System.Object[]] $Report, + + [Parameter(Mandatory)] + [System.String] $ExportFileName, + + [Parameter(Mandatory)] + [System.String] $WorksheetName + ) + + # Define the base directory for all exports + [System.String] $baseDirectory = "C:\Temp" + # Construct the full export path + [System.String] $exportPath = Join-Path -Path $baseDirectory -ChildPath $ExportFileName + + # Verify output directory + if (-not (Test-Path -Path $baseDirectory)) { + Write-Host "Directory [$baseDirectory] does not exist. It will be created during export." -ForegroundColor Yellow + } + + # Define Excel export parameters + [System.Collections.Hashtable] $excelParams = @{ + Path = $exportPath + WorksheetName = $WorksheetName + AutoSize = $true + FreezeTopRow = $true + BoldTopRow = $true + } + + try { + # Ensure output directory exists + if (-not (Test-Path -Path $baseDirectory)) { + New-Item -Path $baseDirectory -ItemType Directory -Force | Out-Null + Write-Host "Created directory: [$baseDirectory]" -ForegroundColor Green + } + + # Export data to Excel + $Report | Export-Excel @excelParams + Write-Host "Report generated at: [$exportPath]" -ForegroundColor Green + } + # Catch errors and exit to report export issues + catch { + Write-Error "Export failed: [$($_.Exception.Message)]" + exit + } +} \ No newline at end of file diff --git a/src/helper-functions/New-AdoAuthenticationHeader.ps1 b/src/helper-functions/New-AdoAuthenticationHeader.ps1 new file mode 100644 index 0000000..dc21d20 --- /dev/null +++ b/src/helper-functions/New-AdoAuthenticationHeader.ps1 @@ -0,0 +1,56 @@ +<# + .SYNOPSIS + Creates a well-formatted Azure DevOps authentication header. + + .DESCRIPTION + This function formats an Azure DevOps Personal Access Token (PAT) into a Basic Authentication header that can be used in REST API calls. + The PAT token owner name (username) is required and should be the username associated with the PAT token, typically an email. + + .PARAMETER PatToken + The Personal Access Token (PAT) generated in Azure DevOps. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + $adoAuthHeader = New-AdoAuthenticationHeader -PatToken "yourPatTokenHere" -PatTokenOwnerName "Ben John" + Invoke-RestMethod -Uri "https://dev.azure.com/organization/_apis/projects" -Headers $adoAuthHeader -Method Get +#> + +function New-AdoAuthenticationHeader { + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [System.String] $PatToken, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [System.String] $PatTokenOwnerName + ) + + # Trim the PAT token to remove any accidental whitespace + $PatToken = $PatToken.Trim() + + # Trim the PatTokenOwnerName to remove any accidental whitespace + $PatTokenOwnerName = $PatTokenOwnerName.Trim() + + # Format the authentication string in the format "username:patToken" + Write-Debug -Message "Combining PatTokenOwnerName and PatToken into a single string: [$PatTokenOwnerName]:[$PatToken]" + $authString = "{0}:{1}" -f $PatTokenOwnerName, $PatToken + + # Encode the authentication string to UTF-8 bytes and then to Base64 + Write-Debug -Message "Encoding the authentication string to Base64" + $utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($authString) + $base64Auth = [System.Convert]::ToBase64String($utf8Bytes) + + # Construct the authentication header hashtable + Write-Debug -Message "Constructing the authentication header hashtable" + [System.Collections.Hashtable]$adoAuthenticationHeader = @{ + 'Content-Type' = 'application/json' + 'Authorization' = "Basic $base64Auth" + } + + return $adoAuthenticationHeader +} \ No newline at end of file diff --git a/src/reports/ArtifactStorageReport.ps1 b/src/reports/ArtifactStorageReport.ps1 new file mode 100644 index 0000000..2c752b1 --- /dev/null +++ b/src/reports/ArtifactStorageReport.ps1 @@ -0,0 +1,148 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Reports on Azure DevOps artifact storage usage across projects. + + .DESCRIPTION + Collects statistics on artifact storage usage by counting builds with artifacts and estimating artifact counts and sizes, + then exports the results to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\ArtifactStorageReport.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure API headers with PAT +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects from Azure DevOps +try { + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan +} catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Iterate through each project +foreach ($project in $projects) { + [System.String] $projectId = $project.id + [System.String] $projectName = [uri]::EscapeDataString($project.name) + Write-Host "Processing project: [$($project.name)]" -ForegroundColor Cyan + + # Initialize counters for build and artifact stats + [System.Int32] $buildCountWithArtifacts = 0 + [System.Int32] $artifactCount = 0 + [System.Double] $totalArtifactSizeMB = 0 + + # Fetch builds for the project + try { + [System.String] $buildsUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/build/builds?api-version=7.1-preview.4" + [System.String] $continuationToken = $null + [System.Object[]] $builds = @() + + # Handle pagination to retrieve all builds + do { + [System.String] $uri = $buildsUri + if ($continuationToken) { + $uri += "&continuationToken=$continuationToken" + } + + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get + $builds += $response.value + $continuationToken = $response.PSObject.Properties['continuationToken']?.Value + + } while ($continuationToken) + + Write-Host "Found [$($builds.Count)] builds in [$($project.name)]." -ForegroundColor Cyan + + # Process each build for artifacts + foreach ($build in $builds) { + [System.String] $buildId = $build.id + try { + # Fetch artifacts for the build + [System.String] $artifactsUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/build/builds/${buildId}/artifacts?api-version=7.1-preview.1" + $artifactsResponse = Invoke-RestMethod -Uri $artifactsUri -Headers $headers -Method Get + $artifacts = $artifactsResponse.value + + if ($artifacts.Count -gt 0) { + $buildCountWithArtifacts++ + $artifactCount += $artifacts.Count + + # Estimate artifact sizes + foreach ($artifact in $artifacts) { + if ($artifact.resource.downloadUrl) { + try { + # Get artifact size via HEAD request + $artifactDetails = Invoke-RestMethod -Uri $artifact.resource.downloadUrl -Headers $headers -Method Head + [System.Int64] $sizeBytes = $artifactDetails.ContentLength + [System.Double] $sizeMB = [math]::Round($sizeBytes / 1MB, 2) + $totalArtifactSizeMB += $sizeMB + } catch { + Write-Warning "Failed to fetch size for artifact [$($artifact.name)] in build [$buildId]: [$($_.Exception.Message)]" + } + } + } + } + } catch { + # Handle 404 errors silently as they indicate no artifacts + if ($_.Exception.Response.StatusCode -eq 404) { + Write-Debug "404 means no artifacts for this build, which is normal; skipping warning." + continue + } + Write-Warning "Failed to fetch artifacts for build [$buildId] in [$($project.name)]: [$($_.Exception.Message)]" + } + } + } + # Catch non-terminating errors and log them without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch builds for [$($project.name)]: [$($_.Exception.Message)]" + } + + # Add project stats to report + $report += [PSCustomObject]@{ + ProjectName = $project.name + TotalBuilds = $builds.Count + BuildsWithArtifacts = $buildCountWithArtifacts + ArtifactCount = $artifactCount + TotalArtifactSizeMB = $totalArtifactSizeMB + } +} + +# Exit if no projects were processed +if ($report.Count -eq 0) { + Write-Host "[No projects found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "ArtifactStorageReport.xlsx" -WorksheetName "ArtifactStorage" \ No newline at end of file diff --git a/src/reports/AuditStreams.ps1 b/src/reports/AuditStreams.ps1 new file mode 100644 index 0000000..b799e6a --- /dev/null +++ b/src/reports/AuditStreams.ps1 @@ -0,0 +1,69 @@ +#Requires -Modules Az.Accounts, Az.OperationalInsights + +<# + .SYNOPSIS + Reports on audit streams in Azure Log Analytics workspaces. + + .DESCRIPTION + Checks all Log Analytics workspaces in Azure subscriptions for audit stream configurations and + exports the count per subscription to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization (used for context, not API calls here). + + .EXAMPLE + .\AuditStreams.ps1 -Organization "myOrg" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization +) + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Note: This script uses Azure PowerShell (Az modules) and does not directly use the Azure DevOps API, + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all Azure subscriptions +$subscriptions = Get-AzSubscription +# Process each subscription +foreach ($sub in $subscriptions) { + # Set context to current subscription + Set-AzContext -SubscriptionId $sub.Id + # Retrieve all Log Analytics workspaces + $workspaces = Get-AzOperationalInsightsWorkspace + # Initialize audit stream counter + [System.Int32] $auditCount = 0 + # Check each workspace for audit streams + foreach ($workspace in $workspaces) { + try { + # Fetch diagnostic settings for workspace + $settings = Get-AzDiagnosticSetting -ResourceId $workspace.ResourceId + # Count settings with "audit" in name + $auditCount += ($settings | Where-Object { $_.Name -like "*audit*" }).Count + } + # Catch errors and log without breaking to continue processing workspaces + catch { + Write-Warning "Failed to fetch diagnostic settings for workspace [$($workspace.Name)] in subscription [$($sub.Name)]: [$($_.Exception.Message)]" + } + } + # Add subscription stats to report + $report += [PSCustomObject]@{ + SubscriptionName = $sub.Name + SubscriptionId = $sub.Id + AuditStreamsCount = $auditCount + } +} + +# Exit if no audit streams were found +if ($report.Count -eq 0) { + Write-Host "[No audit streams found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "AuditStreamResults.xlsx" -WorksheetName "AuditStreams" \ No newline at end of file diff --git a/src/reports/BranchPoliciesOnAllRepos.ps1 b/src/reports/BranchPoliciesOnAllRepos.ps1 new file mode 100644 index 0000000..9437e7c --- /dev/null +++ b/src/reports/BranchPoliciesOnAllRepos.ps1 @@ -0,0 +1,159 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Reports on branch policy configurations across all repositories in an Azure DevOps organization. + + .DESCRIPTION + Retrieves branch policy settings for all repositories in all projects, including minimum reviewers, + automatic reviewers, comment resolution, build validation, and linked work items, and exports the results to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\BranchPoliciesOnAllRepos.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + # Extract project list + $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + # Encode project name for API compatibility + [System.String] $projectName = [uri]::EscapeDataString($project.name) + Write-Host "Processing project: [$($project.name)]" -ForegroundColor Cyan + + # Fetch all Git repositories in the project + try { + # Construct API endpoint for repository listing + [System.String] $reposUri = "https://dev.azure.com/$Organization/$projectName/_apis/git/repositories?api-version=7.1-preview.1" + $reposResponse = Invoke-RestMethod -Uri $reposUri -Headers $headers -Method Get + [System.Object[]] $repositories = $reposResponse.value + Write-Host "Found [$($repositories.Count)] repositories in [$($project.name)]." -ForegroundColor Cyan + if ($repositories.Count -eq 0) { + Write-Debug "No repositories found for project [$($project.name)]. Response: [$($reposResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch repositories for [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Process each repository + foreach ($repository in $repositories) { + [System.String] $repositoryName = $repository.name + [System.String] $repositoryId = $repository.id + Write-Host "Processing repository: [$repositoryName]" -ForegroundColor Cyan + + # Fetch policy configurations for the repository + try { + # Construct API endpoint for policy configurations + [System.String] $policiesUri = "https://dev.azure.com/$Organization/$projectName/_apis/policy/configurations?repositoryId=$repositoryId&api-version=7.1-preview.1" + $policiesResponse = Invoke-RestMethod -Uri $policiesUri -Headers $headers -Method Get + [System.Object[]] $policies = $policiesResponse.value + Write-Debug "Policies response for [$repositoryName]: [$($policiesResponse | ConvertTo-Json -Depth 5)]" + Write-Host "Found [$($policies.Count)] policies in [$repositoryName]." -ForegroundColor Cyan + if ($policies.Count -eq 0) { + Write-Debug "No policies found for repository [$repositoryName]." + } + } + # Catch errors and log without breaking to continue processing other repositories + catch { + Write-Warning "Failed to fetch policies for [$repositoryName] in [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Initialize policy settings with default values + [System.Collections.Hashtable]$policyResults = @{ + Project = $project.name + Repository = $repositoryName + MinReviewers = $false + AutoReviewers = $false + CommentResolution = $false + BuildValidation = $false + LinkedWorkItems = $false + } + + # Evaluate each policy + foreach ($policy in $policies) { + if ($policy.type -and $policy.type.displayName) { + switch ($policy.type.displayName) { + "Minimum number of reviewers" { + $policyResults.MinReviewers = $true + } + "Automatically include code reviewers" { + $policyResults.AutoReviewers = $true + } + "Check for comment resolution" { + $policyResults.CommentResolution = $true + } + "Build validation" { + $policyResults.BuildValidation = $true + } + "Check for linked work items" { + $policyResults.LinkedWorkItems = $true + } + default { + Write-Debug "Unknown policy type [$($policy.type.displayName)] for [$repositoryName]." + } + } + } + else { + Write-Debug "Policy missing type or displayName for [$repositoryName]: [$($policy | ConvertTo-Json -Depth 3)]" + } + } + + # Add policy results to report + $report += [PSCustomObject]$policyResults + } +} + +# Exit if no data collected +if ($report.Count -eq 0) { + Write-Host "[No repositories found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "BranchPoliciesReport.xlsx" -WorksheetName "BranchPolicies" \ No newline at end of file diff --git a/src/reports/BranchStructureReport.ps1 b/src/reports/BranchStructureReport.ps1 new file mode 100644 index 0000000..286cba6 --- /dev/null +++ b/src/reports/BranchStructureReport.ps1 @@ -0,0 +1,132 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Reports on branch structure per repository in Azure DevOps. + + .DESCRIPTION + Collects branch details, including branch name and hierarchical path, for each repository across all projects + and exports the results to an Excel file.The branch path reflects the naming convention used (e.g., feature/subfeature). + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\BranchStructureReport.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + # Extract project list + $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + # Encode project name for API compatibility + [System.String] $projectName = [uri]::EscapeDataString($project.name) + Write-Host "Processing project: [$($project.name)]" -ForegroundColor Cyan + + # Fetch all Git repositories in the project + try { + # Construct API endpoint for repository listing + [System.String] $reposUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories?api-version=7.1-preview.1" + $reposResponse = Invoke-RestMethod -Uri $reposUri -Headers $headers -Method Get + [System.Object[]] $repositories = $reposResponse.value + Write-Host "Found [$($repositories.Count)] repositories in [$($project.name)]." -ForegroundColor Cyan + if ($repositories.Count -eq 0) { + Write-Debug "No repositories found for project [$($project.name)]. Response: [$($reposResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch repositories for [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Process each repository + foreach ($repository in $repositories) { + [System.String] $repositoryName = $repository.name + [System.String] $repositoryId = $repository.id + Write-Host "Processing repository: [$repositoryName]" -ForegroundColor Cyan + + # Fetch all branch references + try { + # Construct API endpoint for branch listing + [System.String] $branchesUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories/${repositoryId}/refs?api-version=7.1-preview.1" + $branchesResponse = Invoke-RestMethod -Uri $branchesUri -Headers $headers -Method Get + Write-Debug "Raw refs response for [$repositoryName]: [$($branchesResponse | ConvertTo-Json -Depth 5)]" + # Filter for branches only + [System.Object[]] $branches = $branchesResponse.value | Where-Object { $_.name -like "refs/heads/*" } + Write-Host "Found [$($branches.Count)] branches in [$repositoryName]." -ForegroundColor Cyan + if ($branches.Count -eq 0) { + Write-Debug "Filtered branches response for [$repositoryName] (refs/heads/*): [$($branchesResponse.value | ConvertTo-Json -Depth 5)]" + } + } + # Catch errors and log without breaking to continue processing other repositories + catch { + Write-Warning "Failed to fetch branches for [$repositoryName] in [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Process each branch + foreach ($branch in $branches) { + # Extract branch name and path + [System.String] $branchName = $branch.name -replace "refs/heads/", "" + [System.String] $branchPath = $branchName + + # Add branch data to report + $report += [PSCustomObject]@{ + ProjectName = $project.name + RepositoryName = $repositoryName + BranchName = $branchName + BranchPath = $branchPath + } + } + } +} + +# Exit if no data collected +if ($report.Count -eq 0) { + Write-Host "[No repositories or branches found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "BranchStructureReport.xlsx" -WorksheetName "BranchStructure" \ No newline at end of file diff --git a/src/reports/BranchesNotMergedReport.ps1 b/src/reports/BranchesNotMergedReport.ps1 new file mode 100644 index 0000000..cdf9a42 --- /dev/null +++ b/src/reports/BranchesNotMergedReport.ps1 @@ -0,0 +1,170 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Generates a report on branches per repository in Azure DevOps, identifying branches not merged into the main or master branch. + + .DESCRIPTION + This script retrieves all projects, their repositories, and branches using the Azure DevOps REST API. + For each branch, it checks if the latest commit is in the main (or master) branch's history to determine + merge status. The results are exported to an Excel file, showing which branches contain unmerged changes. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) used to authenticate API requests. Must have "Code (Read)" scope. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\BranchesNotMergedReport.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + # Extract project list + $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan +} catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + # Encode project name for API compatibility + [System.String] $projectName = [uri]::EscapeDataString($project.name) + Write-Host "Processing project: [$($project.name)]" -ForegroundColor Cyan + + # Fetch all Git repositories in the project + try { + # Construct API endpoint for repository listing + [System.String] $reposUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories?api-version=7.1-preview.1" + $reposResponse = Invoke-RestMethod -Uri $reposUri -Headers $headers -Method Get + $repositories = $reposResponse.value + Write-Host "Found [$($repositories.Count)] repositories in [$($project.name)]." -ForegroundColor Cyan + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch repositories for [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Process each repository + foreach ($repository in $repositories) { + [System.String] $repositoryName = $repository.name + [System.String] $repositoryId = $repository.id + Write-Host "Processing repository: [$repositoryName]" -ForegroundColor Cyan + + # Fetch all branch references + try { + # Construct API endpoint for branch listing + [System.String] $branchesUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories/${repositoryId}/refs?api-version=7.1-preview.1" + $branchesResponse = Invoke-RestMethod -Uri $branchesUri -Headers $headers -Method Get + # Filter for branches only + [System.Object[]] $branches = $branchesResponse.value | Where-Object { $_.name -like "refs/heads/*" } + Write-Host "Found [$($branches.Count)] branches in [$repositoryName]." -ForegroundColor Cyan + # Log debug info if no branches found + if ($branches.Count -eq 0) { + Write-Debug "Branches response for [$repositoryName]: [$($branchesResponse | ConvertTo-Json -Depth 5)]" + } + } + # Catch errors and log without breaking to continue processing other repositories + catch { + Write-Warning "Failed to fetch branches for [$repositoryName] in [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Identify main branch (main or master) + $mainBranch = $branches | Where-Object { $_.name -eq "refs/heads/main" } + [System.String] $mainBranchName = "main" + if (-not $mainBranch) { + $mainBranch = $branches | Where-Object { $_.name -eq "refs/heads/master" } + $mainBranchName = "master" + } + if (-not $mainBranch) { + Write-Warning "No main or master branch found in [$repositoryName]. Skipping merge checks." + continue + } + [System.String]$mainCommitId = $mainBranch.commit.commitId + + # Process each branch for merge status + foreach ($branch in $branches) { + # Extract branch name + [System.String] $branchName = $branch.name -replace "refs/heads/", "" + [System.String] $mergeStatus = "Unknown" + # Handle main/master branch + if ($branchName -eq $mainBranchName) { + $mergeStatus = "Main Branch" + } else { + # Get branch commit ID + [System.String] $branchCommitId = $branch.commit.commitId + if (-not $branchCommitId) { + Write-Warning "No commit ID found for branch [$branchName] in [$repositoryName]." + $mergeStatus = "No Commits" + } else { + # Check merge status + try { + # Construct API endpoint for commit comparison + [System.String] $commitUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories/${repositoryId}/commits?searchCriteria.itemVersion.version=$branchName&searchCriteria.compareVersion.version=$mainBranchName&api-version=7.1-preview.1" + $commitResponse = Invoke-RestMethod -Uri $commitUri -Headers $headers -Method Get + # Check if branch commit is in main's history + $isMerged = $commitResponse.value | Where-Object { $_.commitId -eq $branchCommitId } + $mergeStatus = if ($isMerged) { "Merged" } else { "Not Merged" } + # Log debug if no commits returned + if (-not $commitResponse.value) { + Write-Debug "No commits returned for [$branchName] vs [$mainBranchName] in [$repositoryName]." + } + } + # Catch errors and log without breaking to continue processing branches + catch { + Write-Warning "Failed to check merge status for branch [$branchName] in [$repositoryName]: [$($_.Exception.Message)]" + } + } + } + + # Add branch data to report + $report += [PSCustomObject]@{ + ProjectName = $project.name + RepositoryName = $repositoryName + BranchName = $branchName + MergeStatus = $mergeStatus + } + } + } +} + +# Exit if no data collected +if ($report.Count -eq 0) { + Write-Host "[No repositories or branches found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "BranchesNotMergedReport.xlsx" -WorksheetName "BranchesNotMerged" \ No newline at end of file diff --git a/src/reports/BranchesPerRepoReport.ps1 b/src/reports/BranchesPerRepoReport.ps1 new file mode 100644 index 0000000..2aae978 --- /dev/null +++ b/src/reports/BranchesPerRepoReport.ps1 @@ -0,0 +1,177 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Reports on branches per repository in Azure DevOps, identifying stale branches. + + .DESCRIPTION + Collects branch details including last commit date, stale status, and last commit ID, then exports the results to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .PARAMETER StaleThresholdDays + Number of days after which a branch is considered stale. + + .EXAMPLE + .\BranchesPerRepoReport.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" -StaleThresholdDays 90 +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName, + [Parameter(Mandatory)] + [System.Int32] $StaleThresholdDays +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Validate PAT and API access +try { + # Test API access with a simple call + [System.String] $testUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4&$top=1" + $testResponse = Invoke-RestMethod -Uri $testUri -Headers $headers -Method Get + Write-Host "PAT validation successful for organization [$Organization]." -ForegroundColor Cyan +} catch { + Write-Error "PAT validation failed: [$($_.Exception.Message)]. Ensure PAT has 'Code (Read)' scope." + exit +} + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + # Extract project list + $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan +} catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + # Encode project name for API compatibility + [System.String] $projectName = [uri]::EscapeDataString($project.name) + Write-Host "Processing project: [$($project.name)]" -ForegroundColor Cyan + + # Fetch all Git repositories in the project + try { + # Construct API endpoint for repository listing + [System.String] $reposUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories?api-version=7.1-preview.1" + $reposResponse = Invoke-RestMethod -Uri $reposUri -Headers $headers -Method Get + [System.Object[]] $repositories = $reposResponse.value + Write-Host "Found [$($repositories.Count)] repositories in [$($project.name)]." -ForegroundColor Cyan + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch repositories for [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Process each repository + foreach ($repository in $repositories) { + [System.String] $repositoryName = $repository.name + [System.String] $repositoryId = $repository.id + Write-Host "Processing repository: [$repositoryName]" -ForegroundColor Cyan + + # Fetch all branch references + try { + # Construct API endpoint for branch listing + [System.String] $branchesUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories/${repositoryId}/refs?api-version=7.1-preview.1" + $branchesResponse = Invoke-RestMethod -Uri $branchesUri -Headers $headers -Method Get + Write-Debug "Raw refs response for [$repositoryName]: [$($branchesResponse | ConvertTo-Json -Depth 5)]" + # Filter for branches only + [System.Object[]] $branches = $branchesResponse.value | Where-Object { $_.name -like "refs/heads/*" } + Write-Host "Found [$($branches.Count)] branches in [$repositoryName]." -ForegroundColor Cyan + if ($branches.Count -eq 0) { + Write-Debug "Filtered branches response for [$repositoryName] (refs/heads/*): [$($branchesResponse.value | ConvertTo-Json -Depth 5)]" + } + } + # Catch errors and log without breaking to continue processing other repositories + catch { + Write-Warning "Failed to fetch branches for [$repositoryName] in [$($project.name)]: [$($_.Exception.Message)]" + continue + } + + # Process each branch + foreach ($branch in $branches) { + # Extract branch name + [System.String] $branchName = $branch.name -replace "refs/heads/", "" + [System.String] $lastCommitId = $branch.objectId + [System.String] $lastCommitDate = "Unknown" + [System.String] $isStale = "Unknown" + + # Validate commit ID + if (-not $lastCommitId) { + Write-Warning "No commit ID found for branch [$branchName] in [$repositoryName]." + Write-Debug "Branch [$branchName] object: [$($branch | ConvertTo-Json -Depth 3)]" + $lastCommitId = "None" + } else { + # Fetch commit details + try { + # Construct API endpoint for commit details + [System.String] $commitUri = "https://dev.azure.com/${Organization}/${projectName}/_apis/git/repositories/${repositoryId}/commits/${lastCommitId}?api-version=7.1-preview.1" + $commitResponse = Invoke-RestMethod -Uri $commitUri -Headers $headers -Method Get + Write-Debug "Commit response for [$branchName] in [$repositoryName]: [$($commitResponse | ConvertTo-Json -Depth 5)]" + + if ($commitResponse.committer -and $commitResponse.committer.date) { + # Calculate stale status based on commit date + [System.DateTime] $lastCommitDateObj = [datetime] $commitResponse.committer.date + $lastCommitDate = $lastCommitDateObj.ToString("yyyy-MM-dd") + [System.Int32] $daysSinceLastCommit = ((Get-Date) - $lastCommitDateObj).Days + $isStale = if ($daysSinceLastCommit -ge $StaleThresholdDays) { "Yes" } else { "No" } + } else { + Write-Warning "No valid committer date for branch [$branchName] in [$repositoryName] (Commit ID: [$lastCommitId])." + Write-Debug "Invalid or missing committer date in commit response for [$branchName]: [$($commitResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log without breaking to continue processing branches + catch { + Write-Warning "Failed to fetch commit details for branch [$branchName] in [$repositoryName]: [$($_.Exception.Message)]" + Write-Debug "Commit API error details: [$($_.Exception | ConvertTo-Json -Depth 3)]" + } + } + + # Add branch data to report + $report += [PSCustomObject]@{ + ProjectName = $project.name + RepositoryName = $repositoryName + BranchName = $branchName + LastCommitDate = $lastCommitDate + Stale = $isStale + LastCommitId = $lastCommitId + } + } + } +} + +# Exit if no data collected +if ($report.Count -eq 0) { + Write-Host "[No repositories or branches found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "BranchesPerRepoReport.xlsx" -WorksheetName "Branches" \ No newline at end of file diff --git a/src/reports/InactiveUsers.ps1 b/src/reports/InactiveUsers.ps1 new file mode 100644 index 0000000..54327ba --- /dev/null +++ b/src/reports/InactiveUsers.ps1 @@ -0,0 +1,98 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Identifies inactive Azure DevOps users. + + .DESCRIPTION + Fetches user entitlements and identifies users who have not accessed the organization for over 90 days or + have never logged in, exporting the results to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\InactiveUsers.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all user entitlements +try { + # Construct API endpoint for user entitlements + [System.String] $usersUri = "https://vsaex.dev.azure.com/$Organization/_apis/userentitlements?api-version=7.1-preview.1" + $usersResponse = Invoke-RestMethod -Uri $usersUri -Headers $headers -Method Get + [System.Object[]] $userEntitlements = $usersResponse.value + Write-Host "Found [$($userEntitlements.Count)] user entitlements." -ForegroundColor Cyan + if ($userEntitlements.Count -eq 0) { + Write-Debug "No user entitlements found for organization [$Organization]. Response: [$($usersResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report user fetch issues +catch { + Write-Error "Failed to fetch user entitlements: [$($_.Exception.Message)]" + exit +} + +# Define threshold for inactivity (90 days ago) +[System.DateTime] $thresholdDate = (Get-Date).AddDays(-90) + +# Process each user entitlement +foreach ($user in $userEntitlements) { + # Extract last accessed date + [System.DateTime] $lastAccessed = if ($user.lastAccessedDate -and $user.lastAccessedDate -ne "0001-01-01T00:00:00Z") { + [System.DateTime] $user.lastAccessedDate + } else { + $null + } + + # Check for inactivity + if (-not $lastAccessed -or $lastAccessed -lt $thresholdDate) { + # Format last accessed date or use "Never" + [System.String] $lastAccessedFormatted = if ($lastAccessed) { + $lastAccessed.ToString("yyyy-MM-dd") + } else { + "Never" + } + + # Add user to report + $report += [PSCustomObject]@{ + User = $user.user.principalName + LastAccessed = $lastAccessedFormatted + } + } +} + +# Exit if no inactive users found +if ($report.Count -eq 0) { + Write-Host "[No inactive users found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "InactiveUsersReport.xlsx" -WorksheetName "InactiveUsers" \ No newline at end of file diff --git a/src/reports/InstalledExtensions.ps1 b/src/reports/InstalledExtensions.ps1 new file mode 100644 index 0000000..03f3b57 --- /dev/null +++ b/src/reports/InstalledExtensions.ps1 @@ -0,0 +1,79 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Retrieves and exports a list of installed Azure DevOps extensions. + + .DESCRIPTION + Queries the Azure DevOps REST API to fetch installed extensions for the specified organization and exports their details, + including extension name, publisher, and version, to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\InstalledExtensions.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all installed extensions +try { + # Construct API endpoint for installed extensions + [System.String] $extensionsUri = "https://extmgmt.dev.azure.com/$Organization/_apis/extensionmanagement/installedextensions?api-version=7.2-preview.2" + $extensionsResponse = Invoke-RestMethod -Uri $extensionsUri -Headers $headers -Method Get + [System.Object[]] $installedExtensions = $extensionsResponse.value + Write-Host "Found [$($installedExtensions.Count)] installed extensions." -ForegroundColor Cyan + if ($installedExtensions.Count -eq 0) { + Write-Debug "No installed extensions found for organization [$Organization]. Response: [$($extensionsResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report extensions fetch issues +catch { + Write-Error "Failed to fetch installed extensions: [$($_.Exception.Message)]" + exit +} + +# Process each extension +foreach ($extension in $installedExtensions) { + # Add extension details to report + $report += [PSCustomObject]@{ + ExtensionName = $extension.extensionName + Publisher = $extension.publisherName + Version = $extension.version + } +} + +# Exit if no extensions found +if ($report.Count -eq 0) { + Write-Host "[No extensions found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "InstalledExtensions.xlsx" -WorksheetName "Extensions" \ No newline at end of file diff --git a/src/reports/NonDomainUsers.ps1 b/src/reports/NonDomainUsers.ps1 new file mode 100644 index 0000000..4ed2185 --- /dev/null +++ b/src/reports/NonDomainUsers.ps1 @@ -0,0 +1,86 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Lists non-domain (external) users in Azure DevOps. + + .DESCRIPTION + Identifies users whose email addresses do not match a specified domain pattern (e.g., external users) + and exports their details to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .PARAMETER DomainPattern + The domain pattern to identify internal users (e.g., "@mycompany.com"). + + .EXAMPLE + .\NonDomainUsers.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" -DomainPattern "@mycompany.com" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName, + [Parameter(Mandatory)] + [System.String] $DomainPattern +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all user entitlements +try { + # Construct API endpoint for user entitlements + [System.String] $usersUri = "https://vsaex.dev.azure.com/$Organization/_apis/userentitlements?api-version=7.1-preview.1" + $usersResponse = Invoke-RestMethod -Uri $usersUri -Headers $headers -Method Get + [System.Object[]] $userEntitlements = $usersResponse.value + Write-Host "Found [$($userEntitlements.Count)] user entitlements." -ForegroundColor Cyan + if ($userEntitlements.Count -eq 0) { + Write-Debug "No user entitlements found for organization [$Organization]. Response: [$($usersResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report user fetch issues +catch { + Write-Error "Failed to fetch user entitlements: [$($_.Exception.Message)]" + exit +} + +# Process each user entitlement +foreach ($user in $userEntitlements) { + # Check for non-domain users + if ($user.user.principalName -notmatch [regex]::Escape($DomainPattern)) { + # Add non-domain user to report + $report += [PSCustomObject]@{ + User = $user.user.principalName + } + } +} + +# Exit if no non-domain users found +if ($report.Count -eq 0) { + Write-Host "[No non-domain users found.]" -ForegroundColor Yellow + Write-Debug "No users matched the non-domain pattern for [$DomainPattern]. Total users: [$($userEntitlements.Count)]" + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "NonDomainUsersReport.xlsx" -WorksheetName "NonDomainUsers" \ No newline at end of file diff --git a/src/reports/ProjectAgentPools.ps1 b/src/reports/ProjectAgentPools.ps1 new file mode 100644 index 0000000..1c1e4c6 --- /dev/null +++ b/src/reports/ProjectAgentPools.ps1 @@ -0,0 +1,124 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Reports on Azure DevOps pipeline agent pools. + + .DESCRIPTION + Collects pipeline details and their associated agent pools across all projects in the specified + organization and exports them to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\ProjectAgentPools.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.0" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + [System.Object[]] $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan + if ($projects.Count -eq 0) { + Write-Debug "No projects found for organization [$Organization]. Response: [$($projectsResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + # Encode project name for API compatibility + [System.String] $projectName = [uri]::EscapeDataString($project.name) + Write-Host "Processing project: [$projectName]" -ForegroundColor Cyan + + # Fetch all pipeline definitions for the project + try { + [System.Object[]] $pipelineDefs = @() + [System.String] $pipelinesUri = "https://dev.azure.com/$Organization/$projectName/_apis/build/definitions?api-version=7.0" + [System.String] $continuationToken = $null + + # Handle pagination for pipeline definitions + do { + [System.String] $uri = $pipelinesUri + if ($continuationToken) { + $uri += "&continuationToken=$continuationToken" + } + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get + $pipelineDefs += $response.value + $continuationToken = $response.PSObject.Properties['continuationToken']?.Value + } while ($continuationToken) + + Write-Host "Found [$($pipelineDefs.Count)] pipelines in [$projectName]." -ForegroundColor Cyan + if ($pipelineDefs.Count -eq 0) { + Write-Debug "No pipelines found for project [$projectName]. Response: [$($response | ConvertTo-Json -Depth 3)]" + } + + # Process each pipeline definition + foreach ($pipeline in $pipelineDefs) { + # Determine agent pool name + [System.String] $agentPoolName = "Unknown" + if ($pipeline.pool -and $pipeline.pool.name) { + $agentPoolName = $pipeline.pool.name + } + elseif ($pipeline.queue -and $pipeline.queue.name) { + $agentPoolName = $pipeline.queue.name + } + + # Add pipeline details to report + [System.String] $pipelineName = if ($pipeline.name) { $pipeline.name } else { "Unnamed Pipeline" } + $report += [PSCustomObject]@{ + ProjectName = $project.name + PipelineName = $pipelineName + AgentPoolName = $agentPoolName + } + } + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch pipelines for [$($project.name)]: [$($_.Exception.Message)]" + continue + } +} + +# Exit if no pipelines or agent pools found +if ($report.Count -eq 0) { + Write-Host "[No pipelines or agent pools found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "ProjectAgentPoolsReport.xlsx" -WorksheetName "AgentPools" \ No newline at end of file diff --git a/src/reports/ProjectProcessUsageReport.ps1 b/src/reports/ProjectProcessUsageReport.ps1 new file mode 100644 index 0000000..4b8da00 --- /dev/null +++ b/src/reports/ProjectProcessUsageReport.ps1 @@ -0,0 +1,120 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Generates a report of process usage by projects in Azure DevOps. + + .DESCRIPTION + Fetches all projects in an Azure DevOps organization and reports the process type (e.g., Agile, Scrum) used by each project, + providing a summary of process usage and a detailed breakdown, exported to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\ProjectProcessUsage.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize detailed report array +[System.Object[]] $detailedReport = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + [System.Object[]] $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan + if ($projects.Count -eq 0) { + Write-Debug "No projects found for organization [$Organization]. Response: [$($projectsResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Fetch process type for each project +Write-Host "`nFetching process type for each project..." -ForegroundColor Cyan +foreach ($project in $projects) { + [System.String] $projectId = $project.id + [System.String] $projectName = $project.name + [System.String] $processUri = "https://dev.azure.com/$Organization/_apis/projects/$projectId`?api-version=7.1-preview.4&includeCapabilities=true" + [System.String] $processName = "Unknown" # Default value + + # Fetch process details for the project + try { + $processResponse = Invoke-RestMethod -Uri $processUri -Headers $headers -Method Get + if ($processResponse.PSObject.Properties.Name -contains "capabilities" -and + $processResponse.capabilities.PSObject.Properties.Name -contains "processTemplate") { + $processName = if ($processResponse.capabilities.processTemplate.templateName) { + $processResponse.capabilities.processTemplate.templateName + } else { + "Not Specified" + } + } else { + Write-Warning "No process template data found for project [$projectName]." + Write-Debug "Process response for [$projectName]: [$($processResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch process for project [$projectName]: [$($_.Exception.Message)]" + continue + } + + Write-Host "Project: [$projectName] => Process: [$processName]" + + # Add project process details to report + $detailedReport += [PSCustomObject]@{ + ProjectName = $projectName + Process = $processName + } +} + +# Group projects by process type +$processGroups = $detailedReport | Group-Object -Property Process + +# Generate summary report +$summary = $processGroups | + Select-Object @{Name = "Process Type"; Expression = { $_.Name }}, + @{Name = "Number of Projects"; Expression = { $_.Count }} + +# Display summary report +Write-Host "`n=== Process Usage Summary ===" -ForegroundColor Green +$summary | Format-Table -AutoSize + +# Display detailed breakdown by process +Write-Host "`n=== Detailed Breakdown by Process ===" -ForegroundColor Green +foreach ($group in $processGroups | Sort-Object Name) { + Write-Host "`nProjects using [$($group.Name)]:" -ForegroundColor Yellow + $group.Group | ForEach-Object { Write-Host " - [$($_.ProjectName)]" } +} + +# Call the export function +Export-ToExcel -Report $detailedReport -ExportFileName "ProjectProcessUsageReport.xlsx" -WorksheetName "Process Usage" \ No newline at end of file diff --git a/src/reports/ProjectStatistics.ps1 b/src/reports/ProjectStatistics.ps1 new file mode 100644 index 0000000..bb8e1e9 --- /dev/null +++ b/src/reports/ProjectStatistics.ps1 @@ -0,0 +1,178 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Generates Azure DevOps project statistics with charts. + + .DESCRIPTION + Collects project statistics, including pull requests (created and completed), commits, builds, and releases, + across all projects in the specified organization, and exports them to an Excel file with charts for PRs Created, + PRs Completed, and Builds. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\ProjectStatistics.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Define the base API URL +[System.String] $baseUrl = "https://dev.azure.com/$Organization" + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "$baseUrl/_apis/projects?api-version=7.2-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + [System.Object[]] $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan + if ($projects.Count -eq 0) { + Write-Debug "No projects found for organization [$Organization]. Response: [$($projectsResponse | ConvertTo-Json -Depth 3)]" + Write-Host "[No projects found.]" -ForegroundColor Yellow + exit + } +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + [System.String] $projectName = $project.name + Write-Host "Processing: [$projectName]" -ForegroundColor Cyan + + # Initialize counters for statistics + [System.Int32] $prCreated = 0 + [System.Int32] $prCompleted = 0 + [System.Int32] $commits = 0 + [System.Int32] $builds = 0 + [System.Int32] $releases = 0 + + # Fetch all repositories for the project + try { + [System.String] $reposUri = "$baseUrl/$projectName/_apis/git/repositories?api-version=7.2-preview.1" + $reposResponse = Invoke-RestMethod -Uri $reposUri -Headers $headers -Method Get + [System.Object[]] $repositories = $reposResponse.value + Write-Host "Found [$($repositories.Count)] repositories in [$projectName]." -ForegroundColor Cyan + if ($repositories.Count -eq 0) { + Write-Debug "No repositories found for project [$projectName]. Response: [$($reposResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log, but continue processing the project + catch { + Write-Warning "Failed to fetch repositories for [$projectName]: [$($_.Exception.Message)]" + # Continue processing the project even if repositories fail + } + + # Process each repository if repositories were fetched + if ($repositories) { + foreach ($repository in $repositories) { + [System.String] $repoId = $repository.id + + # Fetch pull requests for the repository + try { + [System.String] $prsUri = "$baseUrl/$projectName/_apis/git/repositories/$repoId/pullrequests?api-version=7.2-preview.1" + $prsResponse = Invoke-RestMethod -Uri $prsUri -Headers $headers -Method Get + $pullRequests = $prsResponse.value + $prCreated += $pullRequests.Count + $prCompleted += ($pullRequests | Where-Object { $_.status -eq "completed" }).Count + Write-Debug "Repository [$($repository.name)] in [$projectName]: PRs Created=[$prCreated], PRs Completed=[$prCompleted]" + } + # Catch errors and log, but continue processing other repositories + catch { + Write-Warning "Failed to fetch pull requests for repository [$($repository.name)] in [$projectName]: [$($_.Exception.Message)]" + continue + } + + # Fetch commits (up to 1000) for the repository + try { + [System.String] $commitsUri = "$baseUrl/$projectName/_apis/git/repositories/$repoId/commits?api-version=7.2-preview.1&`$top=1000" + $commitsResponse = Invoke-RestMethod -Uri $commitsUri -Headers $headers -Method Get + $commits += $commitsResponse.count + Write-Debug "Repository [$($repository.name)] in [$projectName]: Commits=[$commits]" + } + # Catch errors and log, but continue processing other repositories + catch { + Write-Warning "Failed to fetch commits for repository [$($repository.name)] in [$projectName]: [$($_.Exception.Message)]" + continue + } + } + } + + # Fetch build count for the project + try { + [System.String] $buildsUri = "$baseUrl/$projectName/_apis/build/builds?api-version=7.2-preview.7" + $buildsResponse = Invoke-RestMethod -Uri $buildsUri -Headers $headers -Method Get + $builds = $buildsResponse.count + Write-Debug "Project [$projectName]: Builds=[$builds]" + } + # Catch errors and log, but continue processing the project + catch { + Write-Warning "Failed to fetch builds for [$projectName]: [$($_.Exception.Message)]" + $builds = 0 + } + + # Fetch release count for the project + try { + [System.String] $releasesUri = "$baseUrl/$projectName/_apis/release/releases?api-version=7.2-preview.8" + $releasesResponse = Invoke-RestMethod -Uri $releasesUri -Headers $headers -Method Get + $releases = $releasesResponse.count + } + # Catch errors and log, but continue processing the project + catch { + # Only log warning for non-404 errors (404 is expected if no releases exist) + if ($_.Exception.Response.StatusCode -ne 404) { + Write-Warning "Failed to fetch releases for [$projectName]: [$($_.Exception.Message)]" + } + $releases = 0 + } + + # Add project statistics to report + Write-Host "Statistics for [$projectName]: PRs Created=[$prCreated], PRs Completed=[$prCompleted], Commits=[$commits], Builds=[$builds], Releases=[$releases]" -ForegroundColor Cyan + $report += [PSCustomObject]@{ + Project = $projectName + PRsCreated = $prCreated + PRsCompleted = $prCompleted + Commits = $commits + Builds = $builds + Releases = $releases + } +} + +# Proceed to export even if all statistics are 0 +if ($report.Count -eq 0) { + Write-Host "[No projects processed successfully. Check warnings for details.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "ProjectStatisticsReport.xlsx" -WorksheetName "ProjectStats" \ No newline at end of file diff --git a/src/reports/ReposWithoutBranchingPolicies.ps1 b/src/reports/ReposWithoutBranchingPolicies.ps1 new file mode 100644 index 0000000..ceeb874 --- /dev/null +++ b/src/reports/ReposWithoutBranchingPolicies.ps1 @@ -0,0 +1,138 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Reports on repositories without branch policies. + + .DESCRIPTION + Identifies Git repositories across all projects in the specified Azure DevOps organization that + lack branch policies on their default branches and exports the results to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\ReposWithoutBranchingPolicies.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.0" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + [System.Object[]] $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan + if ($projects.Count -eq 0) { + Write-Debug "No projects found for organization [$Organization]. Response: [$($projectsResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + [System.String] $projectName = $project.name + Write-Host "Processing project: [$projectName]" -ForegroundColor Cyan + + # Fetch all repositories for the project + try { + [System.String] $reposUri = "https://dev.azure.com/$Organization/$projectName/_apis/git/repositories?api-version=7.0" + $reposResponse = Invoke-RestMethod -Uri $reposUri -Headers $headers -Method Get + [System.Object[]] $repositories = $reposResponse.value + Write-Host "Found [$($repositories.Count)] repositories in [$projectName]." -ForegroundColor Cyan + if ($repositories.Count -eq 0) { + Write-Debug "No repositories found for project [$projectName]. Response: [$($reposResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch repositories for [$projectName]: [$($_.Exception.Message)]" + continue + } + + # Process each repository + foreach ($repository in $repositories) { + [System.String] $repoName = $repository.name + [System.String] $defaultBranch = if ($repository.defaultBranch) { + $repository.defaultBranch -replace "refs/heads/", "" + } else { + "Not Set" + } + + # Fetch branch policies for the repository + [System.Boolean]$hasPoliciesOnDefaultBranch = $false + try { + [System.String] $policiesUri = "https://dev.azure.com/$Organization/$projectName/_apis/policy/configurations?repositoryId=$($repository.id)&api-version=7.0" + $policiesResponse = Invoke-RestMethod -Uri $policiesUri -Headers $headers -Method Get + $policies = $policiesResponse.value + + # Check if any policy applies to the default branch + if ($defaultBranch -ne "Not Set" -and $policies.Count -gt 0) { + foreach ($policy in $policies) { + if ($policy.settings -and $policy.settings.scope) { + $scopes = $policy.settings.scope + foreach ($scope in $scopes) { + if ($scope.refName -eq "refs/heads/$defaultBranch") { + $hasPoliciesOnDefaultBranch = $true + break + } + } + } + if ($hasPoliciesOnDefaultBranch) { break } + } + } + } + # Catch errors and log without breaking to continue processing other repositories + catch { + Write-Warning "Failed to fetch policies for repository [$repoName] in [$projectName]: [$($_.Exception.Message)]" + continue + } + + # Add repository details to report + $report += [PSCustomObject]@{ + Project = $projectName + RepoName = $repoName + DefaultBranch = $defaultBranch + HasPolicies = if ($hasPoliciesOnDefaultBranch) { "Yes" } else { "No" } + } + } +} + +# Exit if no repositories found +if ($report.Count -eq 0) { + Write-Host "[No repositories found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "GitRepoBranchPolicyReport.xlsx" -WorksheetName "RepoDetails" \ No newline at end of file diff --git a/src/reports/ServiceConnections.ps1 b/src/reports/ServiceConnections.ps1 new file mode 100644 index 0000000..1a3dfff --- /dev/null +++ b/src/reports/ServiceConnections.ps1 @@ -0,0 +1,129 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Generates a report of all Azure DevOps service connections across projects. + + .DESCRIPTION + Retrieves all service connections across all projects in the specified Azure DevOps organization, extracting details + such as connection name, type, service principal ID, creator, and creation date, and exports the data to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + A valid Azure DevOps Personal Access Token (PAT) used for authentication. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\ServiceConnections.ps1 -Organization "myOrg" -PersonalAccessToken "xxxxxxx" -PatTokenOwnerName "Ben John" + + .NOTES + Requires the ImportExcel PowerShell module to be pre-installed. +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Define the base API URL +[System.String] $baseUrl = "https://dev.azure.com/$Organization" + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing + [System.String] $projectsUrl = "$baseUrl/_apis/projects?api-version=7.2-preview.4" + $projectsResponse = Invoke-RestMethod -Uri $projectsUrl -Headers $headers -Method Get + [System.Object[]] $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan + if ($projects.Count -eq 0) { + Write-Debug "No projects found for organization [$Organization]. Response: [$($projectsResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + [System.String] $projectName = $project.name + Write-Host "Processing project: [$projectName]" -ForegroundColor Cyan + + # Fetch service connections for the project + try { + [System.String] $scUrl = "$baseUrl/$projectName/_apis/serviceendpoint/endpoints?api-version=7.2-preview.4" + $scResponse = Invoke-RestMethod -Uri $scUrl -Headers $headers -Method Get + [System.Object[]] $serviceConnections = $scResponse.value + Write-Host "Found [$($serviceConnections.Count)] service connections in [$projectName]." -ForegroundColor Cyan + if ($serviceConnections.Count -eq 0) { + Write-Debug "No service connections found for project [$projectName]. Response: [$($scResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log without breaking to continue processing other projects + catch { + Write-Warning "Failed to fetch service connections for [$projectName]: [$($_.Exception.Message)]" + continue + } + + # Process each service connection + foreach ($connection in $serviceConnections) { + # Extract service principal ID + [System.String] $spnId = if ($connection.authorization?.parameters?.serviceprincipalid) { + $connection.authorization.parameters.serviceprincipalid + } else { + "N/A" + } + + # Extract creator and creation date + [System.String] $createdBy = if ($connection.createdBy?.displayName) { + $connection.createdBy.displayName + } else { + "Unknown" + } + [System.String] $creationDate = if ($connection.creationDate) { + (Get-Date $connection.creationDate).ToString("yyyy-MM-dd HH:mm:ss") + } else { + "N/A" + } + + # Add service connection details to report + $report += [PSCustomObject]@{ + Project = $projectName + ConnectionName = $connection.name + ConnectionType = $connection.type + ServicePrincipalId = $spnId + CreatedBy = $createdBy + CreationDate = $creationDate + } + } +} + +# Exit if no service connections found +if ($report.Count -eq 0) { + Write-Host "[No service connections found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "ServiceConnectionsReport.xlsx" -WorksheetName "ServiceConnections" \ No newline at end of file diff --git a/src/reports/TestPlanUsageReport.ps1 b/src/reports/TestPlanUsageReport.ps1 new file mode 100644 index 0000000..cf5a956 --- /dev/null +++ b/src/reports/TestPlanUsageReport.ps1 @@ -0,0 +1,111 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Reports on Azure DevOps test plan usage across projects. + + .DESCRIPTION + Collects details on test plan usage by identifying which projects in the specified Azure DevOps organization have + test plans enabled and how many actual test plans exist, then exports the results to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\TestPlanUsage.ps1 -Organization "myOrg" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch all projects in the organization +try { + # Construct API endpoint for project listing with capabilities + [System.String] $projectsUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4&includeCapabilities=true" + $projectsResponse = Invoke-RestMethod -Uri $projectsUri -Headers $headers -Method Get + [System.Object[]] $projects = $projectsResponse.value + Write-Host "Found [$($projects.Count)] projects." -ForegroundColor Cyan + if ($projects.Count -eq 0) { + Write-Debug "No projects found for organization [$Organization]. Response: [$($projectsResponse | ConvertTo-Json -Depth 3)]" + } +} +# Catch errors and exit to report project fetch issues +catch { + Write-Error "Failed to fetch projects: [$($_.Exception.Message)]" + exit +} + +# Process each project +foreach ($project in $projects) { + [System.String] $projectId = $project.id + [System.String] $projectName = $project.name + Write-Host "Processing project: [$projectName]" -ForegroundColor Cyan + + # Check if test plans are enabled + [System.String] $testPlansEnabled = "No" + if ($project.capabilities -and $project.capabilities.PSObject.Properties.Name -contains "testPlans") { + $testPlansEnabled = "Yes" + } + + # Fetch actual test plans + [System.Int32] $testPlanCount = 0 + try { + # Construct API endpoint for test plans + [System.String] $testPlansUri = "https://dev.azure.com/$Organization/$projectId/_apis/testplan/plans?api-version=7.1-preview.1" + $testPlansResponse = Invoke-RestMethod -Uri $testPlansUri -Headers $headers -Method Get + $testPlans = $testPlansResponse.value + $testPlanCount = $testPlans.Count + Write-Host "Found [$testPlanCount] test plans in [$projectName]." -ForegroundColor Cyan + if ($testPlanCount -eq 0) { + Write-Debug "No test plans found for project [$projectName]. Response: [$($testPlansResponse | ConvertTo-Json -Depth 3)]" + } + } + # Catch errors and log without breaking to continue processing other projects + catch { + if ($_.Exception.Response.StatusCode -eq 403) { + Write-Warning "Permission denied fetching test plans for [$projectName]. Test plans may exist but are inaccessible." + } else { + Write-Warning "Failed to fetch test plans for [$projectName]: [$($_.Exception.Message)]" + } + } + + # Add project test plan details to report + $report += [PSCustomObject]@{ + ProjectName = $projectName + TestPlansEnabled = $testPlansEnabled + TestPlanCount = $testPlanCount + } +} + +# Exit if no projects found +if ($report.Count -eq 0) { + Write-Host "[No projects found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "TestPlanUsageReport.xlsx" -WorksheetName "TestPlanUsage" \ No newline at end of file diff --git a/src/reports/readme.md b/src/reports/readme.md new file mode 100644 index 0000000..2a55d4e --- /dev/null +++ b/src/reports/readme.md @@ -0,0 +1,3 @@ +# Folder Purpose + +This folder is used to store the PowerShell scripts which fetch the Azure DevOps Project, Branches, Repos statistics \ No newline at end of file diff --git a/tools/private/readme.md b/tools/private/readme.md new file mode 100644 index 0000000..861a030 --- /dev/null +++ b/tools/private/readme.md @@ -0,0 +1,7 @@ +# Purpose + +This folder is designated for private and personal scripts used during debugging +and building processes. These scripts are intended to support development but +are not meant to be included in this repository. + +> NOTE: this whole folder is under gitignore! \ No newline at end of file From a286fb8f3749b911c4d04182f507f57133bf69df Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 17 Apr 2025 15:38:45 +0200 Subject: [PATCH 03/14] deleted the old function and modified psm1 file accordingly --- .../Functions/DevOps.OrganizationPolicy.ps1 | 93 ------------------- .../PSRule.Rules.AzureDevOps.psm1 | 4 - 2 files changed, 97 deletions(-) delete mode 100644 src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 diff --git a/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 b/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 deleted file mode 100644 index b5e148b..0000000 --- a/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPolicy.ps1 +++ /dev/null @@ -1,93 +0,0 @@ -<# - .SYNOPSIS - Get the organization's policies from Azure DevOps - - .DESCRIPTION - Get the organization's policies from Azure DevOps - - .PARAMETER Organization - The name of the Azure DevOps organization. - - .EXAMPLE - Get-AzDevOpsOrganizationPolicy -Organization $Organization -#> - -Function Get-AzDevOpsOrganizationPolicy { - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] - $Organization - ) - if ($null -eq $script:connection) { - throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" - } - $header = $script:connection.GetHeader() - $uri = "https://dev.azure.com/$Organization/_apis/policy/configurations?api-version=7.1-preview.1" - Write-Verbose "URI: $uri" - try { - $policies = Invoke-RestMethod -Uri $uri -Method Get -Headers $header -ContentType 'application/json' - if ($policies -is [string]) { - throw "Authentication failed or policies not found" - } - } - catch { - throw $_.Exception.Message - } - return $policies -} -Export-ModuleMember -Function Get-AzDevOpsOrganizationPolicy - -<# - .SYNOPSIS - Export the organization's policies from Azure DevOps to a JSON file - - .DESCRIPTION - Export the organization's policies from Azure DevOps to a JSON file with .ado.orgpolicies.json extension - - .PARAMETER Organization - The name of the Azure DevOps organization. - - .PARAMETER OutputPath - Output path for JSON files - - .PARAMETER PassThru - Return the exported policies as objects to the pipeline instead of writing to a file - - .EXAMPLE - Export-AzDevOpsOrganizationPolicies -Organization $Organization -OutputPath $OutputPath -#> -function Export-AzDevOpsOrganizationPolicies { - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] - $Organization, - [Parameter(ParameterSetName = 'JsonFile')] - [string] - $OutputPath, - [Parameter(ParameterSetName = 'PassThru')] - [switch] - $PassThru - ) - if ($null -eq $script:connection) { - throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" - } - Write-Verbose "Getting organization policies from Azure DevOps" - $policies = Get-AzDevOpsOrganizationPolicy -Organization $Organization - $policies | Add-Member -MemberType NoteProperty -Name ObjectType -Value 'Azure.DevOps.Organization.Policies' - $policies | Add-Member -MemberType NoteProperty -Name ObjectName -Value ("{0}.OrganizationPolicies" -f $script:connection.Organization) - $policies | Add-Member -MemberType NoteProperty -Name Name -Value "OrganizationPolicies" - $id = @{ - originalId = $null - resourceName = 'OrganizationPolicies' - organization = $script:connection.Organization - } | ConvertTo-Json -Depth 100 - $policies | Add-Member -MemberType NoteProperty -Name id -Value $id - if ($PassThru) { - Write-Output $policies - } else { - $policies | ConvertTo-Json -Depth 10 | Out-File (Join-Path -Path $OutputPath -ChildPath "$Organization.ado.orgpolicies.json") - } -} -Export-ModuleMember -Function Export-AzDevOpsOrganizationPolicies diff --git a/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 b/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 index ac5a0b9..8f30033 100644 --- a/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 +++ b/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 @@ -69,8 +69,6 @@ Function Export-AzDevOpsRuleData { Export-AzDevOpsReleaseDefinitions -Project $Project -PassThru Write-Verbose "Exporting groups" Export-AzDevOpsGroups -Project $Project -PassThru - # Write-Verbose "Exporting users" - # Export-AzDevOpsUsers -PassThru Write-Verbose "Exporting retention settings" Export-AzDevOpsRetentionSettings -Project $Project -PassThru } else { @@ -93,8 +91,6 @@ Function Export-AzDevOpsRuleData { Export-AzDevOpsReleaseDefinitions -Project $Project -OutputPath $OutputPath Write-Verbose "Exporting groups" Export-AzDevOpsGroups -Project $Project -OutputPath $OutputPath - # Write-Verbose "Exporting users" - # Export-AzDevOpsUsers -OutputPath $OutputPath Write-Verbose "Exporting retention settings" Export-AzDevOpsRetentionSettings -Project $Project -OutputPath $OutputPath } From bd7d571d060371b4dd2965264f082db551b029f9 Mon Sep 17 00:00:00 2001 From: gajendra Date: Fri, 18 Apr 2025 17:53:36 +0200 Subject: [PATCH 04/14] added organization pipeline settings function file --- .../DevOps.OrganizationPipelinesSettings.ps1 | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPipelinesSettings.ps1 diff --git a/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPipelinesSettings.ps1 b/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPipelinesSettings.ps1 new file mode 100644 index 0000000..b1f3159 --- /dev/null +++ b/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationPipelinesSettings.ps1 @@ -0,0 +1,249 @@ +<# + .SYNOPSIS + Retrieves Azure DevOps organization-level pipeline settings. + + .DESCRIPTION + This function calls the internal Contribution HierarchyQuery endpoint used by the Azure DevOps portal + to retrieve pipeline security, policy, and control settings for the entire organization. + + .PARAMETER Organization + The name of your Azure DevOps organization (e.g. 'contoso'). + + .PARAMETER AccessToken + A valid Azure DevOps Bearer token with access to organization settings. + + .EXAMPLE + Invoke-AdoPipelineSettingsQuery -Organization "MyOrg" -AccessToken $token +#> + +function Read-AdoOrganizationPipelinesSettings { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Organization, + + [Parameter(Mandatory = $true)] + [string] + $AccessToken + ) + + # Define endpoint for pipeline settings query + $uri = "https://dev.azure.com/$Organization/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" + + # Set headers for authentication + $headers = @{ + Authorization = "Bearer $AccessToken" + "Content-Type" = "application/json" + } + + # Build request body for settings query + $body = @( + @{ + contributionIds = @("ms.vss-build-web.pipelines-org-settings-data-provider") + dataProviderContext = @{ + properties = @{ + sourcePage = @{ + url = "https://dev.azure.com/$Organization/_settings/pipelinessettings" + routeId = "ms.vss-admin-web.collection-admin-hub-route" + routeValues = @{ + adminPivot = "pipelinessettings" + controller = "ContributedPage" + action = "Execute" + serviceHost = "00000000-0000-0000-0000-000000000000 ($Organization)" + } + } + } + } + } + ) | ConvertTo-Json -Depth 10 + + try { + # Invoke API to retrieve settings + $rawResponse = Invoke-WebRequest -Uri $uri -Method Post -Headers $headers -Body $body -UseBasicParsing + + # Validate response for authentication errors + if ($rawResponse.Content -match ' + + function Export-AdoOrganizationPipelinesSettings { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Organization, + + [Parameter(Mandatory = $true)] + [string] + $AccessToken, + + [Parameter(ParameterSetName = 'JsonFile')] + [string] + $OutputPath, + + [Parameter(ParameterSetName = 'PassThru')] + [switch] + $PassThru + ) + + # Check for active connection + if ($null -eq $script:connection) { + throw 'Not connected to Azure DevOps. Run Connect-AzDevOps first.' + } + + # Retrieve pipeline settings + try { + $settings = Read-AdoOrganizationPipelinesSettings -Organization $Organization -AccessToken $AccessToken + if ($null -eq $settings) { + throw "No Organization pipeline settings returned from Read-AdoOrganizationPipelinesSettings." + } + } + catch { + throw "Failed to get Organization pipeline settings from Azure DevOps: $($_.Exception.Message)" + } + + # Process settings into exportable format + $settingsDetails = @() + $settingsObject = [PSCustomObject]@{ + statusBadgesArePrivate = $settings.statusBadgesArePrivate + enforceSettableVar = $settings.enforceSettableVar + enforceJobAuthScope = $settings.enforceJobAuthScope + enforceJobAuthScopeForReleases = $settings.enforceJobAuthScopeForReleases + enforceReferencedRepoScopedToken = $settings.enforceReferencedRepoScopedToken + disableStageChooser = $settings.disableStageChooser + disableClassicBuildPipelineCreation = $settings.disableClassicBuildPipelineCreation + disableClassicReleasePipelineCreation = $settings.disableClassicReleasePipelineCreation + disableInBoxTasksVar = $settings.disableInBoxTasksVar + disableMarketplaceTasksVar = $settings.disableMarketplaceTasksVar + disableNode6TasksVar = $settings.disableNode6TasksVar + enableShellTasksArgsSanitizing = $settings.enableShellTasksArgsSanitizing + forkProtectionEnabled = $settings.forkProtectionEnabled + buildsEnabledForForks = $settings.buildsEnabledForForks + enforceJobAuthScopeForForks = $settings.enforceJobAuthScopeForForks + enforceNoAccessToSecretsFromForks = $settings.enforceNoAccessToSecretsFromForks + disableImpliedYAMLCiTrigger = $settings.disableImpliedYAMLCiTrigger + auditEnforceSettableVar = $settings.auditEnforceSettableVar + isTaskLockdownFeatureEnabled = $settings.isTaskLockdownFeatureEnabled + hasManagePipelinePoliciesPermission = $settings.hasManagePipelinePoliciesPermission + isCommentRequiredForPullRequest = $settings.isCommentRequiredForPullRequest + requireCommentsForNonTeamMembersOnly = $settings.requireCommentsForNonTeamMembersOnly + requireCommentsForNonTeamMemberAndNonContributors = $settings.requireCommentsForNonTeamMemberAndNonContributors + enableShellTasksArgsSanitizingAudit = $settings.enableShellTasksArgsSanitizingAudit + } + + # Add metadata properties + $settingsObject | Add-Member -MemberType NoteProperty -Name ObjectType -Value 'Azure.DevOps.Organization.Pipelines.Settings' -Force + $settingsObject | Add-Member -MemberType NoteProperty -Name ObjectName -Value "$($script:connection.Organization).OrganizationPipelineSettings" -Force + $settingsObject | Add-Member -MemberType NoteProperty -Name name -Value "OrganizationPipelineSettings" -Force + + # Create structured id object + $id = @{ + originalId = $null + resourceName = "OrganizationPipelineSettings" + organization = $script:connection.Organization + } | ConvertTo-Json -Depth 100 + $settingsObject | Add-Member -MemberType NoteProperty -Name id -Value $id -Force + + $settingsDetails += $settingsObject + + # Output based on parameters + if ($PassThru) { + Write-Output $settingsDetails + } + else { + $settingsDetails | ConvertTo-Json -Depth 100 | Out-File -FilePath "$OutputPath\OrganizationPipelineSettings.ado.json" + } + } + + Export-ModuleMember -Function Export-AdoOrganizationPipelinesSettings \ No newline at end of file From 2032b6d6b2035723bfa8835f20bcc394cb12e480 Mon Sep 17 00:00:00 2001 From: gajendra Date: Fri, 18 Apr 2025 17:56:23 +0200 Subject: [PATCH 05/14] Modified the module files to use bearer token for organization authentication --- .../Classes/AzureDevOpsConnection.ps1 | 64 ++++++---- .../Functions/Common.ps1 | 111 +++++++++++------- .../PSRule.Rules.AzureDevOps.psm1 | 34 +++++- 3 files changed, 141 insertions(+), 68 deletions(-) diff --git a/src/PSRule.Rules.AzureDevOps/Classes/AzureDevOpsConnection.ps1 b/src/PSRule.Rules.AzureDevOps/Classes/AzureDevOpsConnection.ps1 index f792443..7d2fd41 100644 --- a/src/PSRule.Rules.AzureDevOps/Classes/AzureDevOpsConnection.ps1 +++ b/src/PSRule.Rules.AzureDevOps/Classes/AzureDevOpsConnection.ps1 @@ -2,10 +2,10 @@ # # Path: src/PSRule.Rules.AzureDevOps/Functions/Connection.ps1 # This class contains methods to connect to Azure DevOps Rest API -# using a service principal, managed identity or personal access token (PAT). -# it provides an authentication header which is refreshed automatically when it expires. +# using a service principal, managed identity, personal access token (PAT), +# or Bearer token (OAuth 2.0 access token). It provides an authentication +# header which is refreshed automatically when it expires for supported auth types. # -------------------------------------------------- -# class AzureDevOpsConnection { [string]$Organization @@ -18,7 +18,6 @@ class AzureDevOpsConnection { [System.DateTime]$TokenExpires [string]$AuthType [string]$TokenType - # Constructor for Service Principal AzureDevOpsConnection( @@ -27,7 +26,6 @@ class AzureDevOpsConnection { [string]$ClientSecret, [string]$TenantId, [string]$TokenType = 'FullAccess' - ) { $this.Organization = $Organization @@ -38,6 +36,7 @@ class AzureDevOpsConnection { $this.Token = $null $this.TokenExpires = [System.DateTime]::MinValue $this.TokenType = $TokenType + $this.AuthType = 'ServicePrincipal' # Get a token for the Azure DevOps REST API $this.GetServicePrincipalToken() @@ -51,17 +50,19 @@ class AzureDevOpsConnection { { $this.Organization = $Organization # Get the Managed Identity token endpoint for the Azure DevOps REST API - if(-not $env:IDENTITY_ENDPOINT) { + if (-not $env:IDENTITY_ENDPOINT) { $env:IDENTITY_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token" } - if($env:ADO_MSI_CLIENT_ID) { + if ($env:ADO_MSI_CLIENT_ID) { $this.TokenEndpoint = "$($env:IDENTITY_ENDPOINT)?resource=499b84ac-1321-427f-aa17-267ca6975798&api-version=2019-08-01&client_id=$($env:ADO_MSI_CLIENT_ID)" - } else { + } + else { $this.TokenEndpoint = "$($env:IDENTITY_ENDPOINT)?resource=499b84ac-1321-427f-aa17-267ca6975798&api-version=2019-08-01" } $this.Token = $null $this.TokenExpires = [System.DateTime]::MinValue $this.TokenType = $TokenType + $this.AuthType = 'ManagedIdentity' # Get a token for the Azure DevOps REST API $this.GetManagedIdentityToken() @@ -79,11 +80,32 @@ class AzureDevOpsConnection { $this.Token = $null $this.TokenExpires = [System.DateTime]::MaxValue $this.TokenType = $TokenType + $this.AuthType = 'PAT' # Get a token for the Azure DevOps REST API $this.GetPATToken() } + # Constructor for Bearer Token + AzureDevOpsConnection( + [string]$Organization, + [string]$AccessToken, + [string]$TokenType = 'FullAccess', + [switch]$Bearer + ) + { + $this.Organization = $Organization + $this.Token = "Bearer $AccessToken" + $this.TokenExpires = [System.DateTime]::Now.AddHours(1) # Default 1-hour expiry + $this.TokenType = $TokenType + $this.AuthType = 'Bearer' + + # Validate token format + if (-not $AccessToken -or $AccessToken -notmatch "^[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+$") { + throw "Invalid Bearer token format. Ensure the token is a valid JWT." + } + } + # Get a token for the Azure DevOps REST API using a service principal [void]GetServicePrincipalToken() { @@ -93,14 +115,9 @@ class AzureDevOpsConnection { client_secret = $this.ClientSecret scope = '499b84ac-1321-427f-aa17-267ca6975798/.default' } - # URL encode the client secret and id - $secret = [System.Web.HttpUtility]::UrlEncode($this.ClientSecret) - $id = [System.Web.HttpUtility]::UrlEncode($this.ClientId) - #$body = "client_id=$($id)&client_secret=$($secret)&scope=499b84ac-1321-427f-aa17-267ca6975798/.default&grant_type=client_credentials" $header = @{ 'Content-Type' = 'application/x-www-form-urlencoded' } - # POST as form url encoded body using the token endpoint $response = Invoke-RestMethod -Uri $this.TokenEndpoint -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded' -Headers $header $this.Token = "Bearer $($response.access_token)" $this.TokenExpires = [System.DateTime]::Now.AddSeconds($response.expires_in) @@ -111,14 +128,14 @@ class AzureDevOpsConnection { [void]GetManagedIdentityToken() { $header = @{} - If($env:IDENTITY_HEADER) { - $header = @{ 'X-IDENTITY-HEADER' = "$env:IDENTITY_HEADER" ; Metadata = 'true'} - } else { + If ($env:IDENTITY_HEADER) { + $header = @{ 'X-IDENTITY-HEADER' = "$env:IDENTITY_HEADER" ; Metadata = 'true' } + } + else { $header = @{ Metadata = 'true' } } $response = Invoke-RestMethod -Uri $this.TokenEndpoint -Method Get -Headers $header $this.Token = "Bearer $($response.access_token)" - # Get token expiration time from the expires_on property and convert it from unix to a DateTime object $this.TokenExpires = (Get-Date 01.01.1970).AddSeconds($response.expires_on) $this.AuthType = 'ManagedIdentity' } @@ -126,16 +143,15 @@ class AzureDevOpsConnection { # Get a token for the Azure DevOps REST API using a personal access token (PAT) [void]GetPATToken() { - # base64 encode the PAT $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes((":$($this.PAT)"))) $this.Token = 'Basic ' + $base64AuthInfo $this.AuthType = 'PAT' } - # Get the the up to date authentication header for the Azure DevOps REST API + # Get the up-to-date authentication header for the Azure DevOps REST API [System.Collections.Hashtable]GetHeader() { - # If the token is expired, get a new one + # If the token is expired, attempt to refresh (except for Bearer and PAT) if ($this.TokenExpires -lt [System.DateTime]::Now) { switch ($this.AuthType) { 'ServicePrincipal' { @@ -145,13 +161,17 @@ class AzureDevOpsConnection { $this.GetManagedIdentityToken() } 'PAT' { - # PAT tokens don't expire + # PAT tokens don't expire in this context + } + 'Bearer' { + throw "Bearer token has expired. Please provide a new token via Connect-AzDevOps." } } } $header = @{ Authorization = $this.Token + 'Content-Type' = 'application/json' } return $header } -} +} \ No newline at end of file diff --git a/src/PSRule.Rules.AzureDevOps/Functions/Common.ps1 b/src/PSRule.Rules.AzureDevOps/Functions/Common.ps1 index c35557b..cc8a53b 100644 --- a/src/PSRule.Rules.AzureDevOps/Functions/Common.ps1 +++ b/src/PSRule.Rules.AzureDevOps/Functions/Common.ps1 @@ -1,87 +1,114 @@ -<# + <# .SYNOPSIS - Connect to Azure DevOps for a session using a Service Principal, Managed Identity or Personal Access Token (PAT) + Connects to an Azure DevOps organization for use with PSRule.Rules.AzureDevOps cmdlets. .DESCRIPTION - Connect to Azure DevOps for a session using a Service Principal, Managed Identity or Personal Access Token (PAT) + The Connect-AzDevOps function establishes a connection to an Azure DevOps organization using one of several authentication methods: Personal Access Token (PAT), Service Principal, Managed Identity, or Bearer token. The connection details are stored in a script-level variable for use by other cmdlets in the PSRule.Rules.AzureDevOps module. .PARAMETER Organization - Organization name for Azure DevOps + The name of the Azure DevOps organization to connect to. .PARAMETER PAT - Personal Access Token (PAT) for Azure DevOps + A Personal Access Token (PAT) used to authenticate to Azure DevOps. Used with the 'Pat' parameter set. + + .PARAMETER TenantId + The Microsoft Entra ID tenant ID for Service Principal authentication. Used with the 'ServicePrincipal' parameter set. .PARAMETER ClientId - Client ID for Service Principal + The client ID of the Service Principal. Used with the 'ServicePrincipal' parameter set. .PARAMETER ClientSecret - Client Secret for Service Principal - - .PARAMETER TenantId - Tenant ID for Service Principal + The client secret of the Service Principal. Used with the 'ServicePrincipal' parameter set. - .PARAMETER AuthType - Authentication type for Azure DevOps (PAT, ServicePrincipal, ManagedIdentity) + .PARAMETER ManagedIdentity + Specifies that a Managed Identity should be used for authentication. Used with the 'ManagedIdentity' parameter set. - .PARAMETER TokenType - Token type for Azure DevOps (FullAccess, FineGrained, ReadOnly) + .PARAMETER AccessToken + A Bearer token (e.g., OAuth 2.0 access token from Microsoft Entra ID) used to authenticate to Azure DevOps. Used with the 'Bearer' parameter set. .EXAMPLE - Connect-AzDevOps -Organization $Organization -PAT $PAT + Connect-AzDevOps -Organization "MyOrg" -PAT "abc123" + Connects to the "MyOrg" organization using a Personal Access Token. .EXAMPLE - Connect-AzDevOps -Organization $Organization -ClientId $ClientId -ClientSecret $ClientSecret -TenantId $TenantId -AuthType ServicePrincipal + Connect-AzDevOps -Organization "MyOrg" -AccessToken "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik..." + Connects to the "MyOrg" organization using a Bearer token. .EXAMPLE - Connect-AzDevOps -Organization $Organization -AuthType ManagedIdentity + Connect-AzDevOps -Organization "MyOrg" -TenantId "00000000-0000-0000-0000-000000000000" -ClientId "11111111-1111-1111-1111-111111111111" -ClientSecret "secret" + Connects to the "MyOrg" organization using a Service Principal. .EXAMPLE - Connect-AzDevOps -Organization $Organization -PAT $PAT -AuthType PAT + Connect-AzDevOps -Organization "MyOrg" -ManagedIdentity + Connects to the "MyOrg" organization using a Managed Identity. -#> -Function Connect-AzDevOps { + .NOTES + - The Bearer token must have appropriate permissions for Azure DevOps APIs (e.g., read access to projects and settings). + - Bearer tokens typically expire after 1 hour; re-run Connect-AzDevOps with a new token if expired. + - The connection is stored in a script-level variable and persists for the session. + + .LINK + https://docs.microsoft.com/en-us/rest/api/azure/devops/ + #> + function Connect-AzDevOps { [CmdletBinding()] - [OutputType([AzureDevOpsConnection])] param ( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory = $true)] [string] $Organization, - [Parameter(ParameterSetName = 'PAT')] + + [Parameter(Mandatory = $true, ParameterSetName = 'Pat')] [string] $PAT, - [Parameter(ParameterSetName = 'ServicePrincipal', Mandatory=$true)] + + [Parameter(Mandatory = $true, ParameterSetName = 'ServicePrincipal')] + [string] + $TenantId, + + [Parameter(Mandatory = $true, ParameterSetName = 'ServicePrincipal')] [string] $ClientId, - [Parameter(ParameterSetName = 'ServicePrincipal', Mandatory=$true)] + + [Parameter(Mandatory = $true, ParameterSetName = 'ServicePrincipal')] [string] $ClientSecret, - [Parameter(ParameterSetName = 'ServicePrincipal', Mandatory=$true)] - [string] - $TenantId, - [Parameter()] - [ValidateSet('PAT', 'ServicePrincipal', 'ManagedIdentity')] - [string] - $AuthType = 'PAT', - [Parameter()] - [ValidateSet('FullAccess', 'FineGrained', 'ReadOnly')] + + [Parameter(Mandatory = $true, ParameterSetName = 'ManagedIdentity')] + [switch] + $ManagedIdentity, + + [Parameter(Mandatory = $true, ParameterSetName = 'Bearer')] [string] - $TokenType = 'FullAccess' + $AccessToken ) - switch ($AuthType) { - 'PAT' { - $connection = [AzureDevOpsConnection]::new($Organization, $PAT, $TokenType) + + switch ($PSCmdlet.ParameterSetName) { + 'Pat' { + $script:connection = [AzureDevOpsConnection]::new($Organization, $PAT) } 'ServicePrincipal' { - $connection = [AzureDevOpsConnection]::new($Organization, $ClientId, $ClientSecret, $TenantId, $TokenType) + $script:connection = [AzureDevOpsConnection]::new($Organization, $ClientId, $ClientSecret, $TenantId) } 'ManagedIdentity' { - $connection = [AzureDevOpsConnection]::new($Organization, $TokenType) + $script:connection = [AzureDevOpsConnection]::new($Organization) + } + 'Bearer' { + $script:connection = [AzureDevOpsConnection]::new($Organization, $AccessToken, 'FullAccess', $true) } } - $script:connection = $connection + + # Verify connection with a simple API call + try { + $uri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.0" + Invoke-RestMethod -Uri $uri -Method Get -Headers $script:connection.GetHeader() | Out-Null + Write-Verbose "Successfully connected to Azure DevOps organization: $Organization" + } + catch { + throw "Failed to connect to Azure DevOps: $($_.Exception.Message)" + } } -# End of Function Connect-AzDevOps +Export-ModuleMember -Function Connect-AzDevOps <# .SYNOPSIS diff --git a/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 b/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 index 8f30033..728e57f 100644 --- a/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 +++ b/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psm1 @@ -14,6 +14,12 @@ Get-ChildItem -Path "$PSScriptRoot/Functions/*.ps1" | ForEach-Object { . $_.FullName } +# Dot source all rule scripts +Get-ChildItem -Path "$PSScriptRoot/*.Rule.ps1" | ForEach-Object { + Write-Verbose "Loading rule file: $_.FullName" + . $_.FullName +} + <# .SYNOPSIS Run all JSON export functions for Azure DevOps for analysis by PSRule @@ -33,22 +39,37 @@ Get-ChildItem -Path "$PSScriptRoot/Functions/*.ps1" | ForEach-Object { .EXAMPLE Export-AzDevOpsRuleData -Project $Project -OutputPath $OutputPath #> -Function Export-AzDevOpsRuleData { +function Export-AzDevOpsRuleData { [CmdletBinding()] param ( - [Parameter(Mandatory)] + [Parameter(Mandatory = $true)] + [string] + $Organization, + + [Parameter(Mandatory = $true)] [string] $Project, - [Parameter(ParameterSetName = 'JsonFile')] + + [Parameter(Mandatory = $true)] [string] $OutputPath, + [Parameter(ParameterSetName = 'PassThru')] [switch] $PassThru ) + if ($null -eq $script:connection) { - throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" + throw 'Not connected to Azure DevOps. Run Connect-AzDevOps first.' + } + + if ($Organization -ne $script:connection.Organization) { + Write-Warning "Provided Organization ($Organization) differs from connected organization ($($script:connection.Organization)). Using connected organization." } + + $org = $script:connection.organization + + if($PassThru) { Write-Verbose "Exporting rule data for project $Project to $OutputPath" Write-Verbose "Exporting project" @@ -71,6 +92,8 @@ Function Export-AzDevOpsRuleData { Export-AzDevOpsGroups -Project $Project -PassThru Write-Verbose "Exporting retention settings" Export-AzDevOpsRetentionSettings -Project $Project -PassThru + Write-Verbose "Exporting OrganizationPipelines settings" + Export-AdoOrganizationPipelinesSettings -Organization $Organization -PassThru } else { Write-Verbose "Exporting rule data for project $Project to $OutputPath" Write-Verbose "Exporting project" @@ -93,8 +116,11 @@ Function Export-AzDevOpsRuleData { Export-AzDevOpsGroups -Project $Project -OutputPath $OutputPath Write-Verbose "Exporting retention settings" Export-AzDevOpsRetentionSettings -Project $Project -OutputPath $OutputPath + Write-Verbose "Exporting OrganizationPipelines settings" + Export-AdoOrganizationPipelinesSettings -Organization $Organization -OutputPath $OutputPath } } + Export-ModuleMember -Function Export-AzDevOpsRuleData -Alias Export-AzDevOpsProjectRuleData # End of Function Export-AzDevOpsRuleData From 7d6adff6574bfd97688a4926a2145acd6f435c2e Mon Sep 17 00:00:00 2001 From: gajendra Date: Fri, 18 Apr 2025 18:08:00 +0200 Subject: [PATCH 06/14] Added rule file for the organization pipeline settings --- ...s.Organization.Pipelines.Settings.Rule.ps1 | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Pipelines.Settings.Rule.ps1 diff --git a/src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Pipelines.Settings.Rule.ps1 b/src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Pipelines.Settings.Rule.ps1 new file mode 100644 index 0000000..e951d67 --- /dev/null +++ b/src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Pipelines.Settings.Rule.ps1 @@ -0,0 +1,141 @@ +# Azure DevOps organization Pipelines settings rules + +# Synopsis: Anonymous access to organization pipeline status badges should be disabled +Rule 'Azure.DevOps.Organization.Pipelines.Settings.DisableAnonymousBadgeAccess' ` + -Ref 'ADO-OPS-001' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Anonymous access to organization pipeline status badges should be disabled to prevent unauthorized access to pipeline status. + Reason 'Anonymous access to organization pipeline status badges is enabled.' + Recommend 'Enable `Disable anonymous access to badges` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops + $Assert.HasField($TargetObject, "statusBadgesArePrivate", $true) + $Assert.HasFieldValue($TargetObject, "statusBadgesArePrivate", $true) +} + +# Synopsis: Variables settable at queue time should be limited in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.LimitSettableVariables' ` + -Ref 'ADO-OPS-002' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Limiting variables that can be set at queue time in organization settings prevents unauthorized changes to pipeline behavior. + Reason 'Variables that can be set at queue time are not limited in organization settings.' + Recommend 'Enable `Limit variables that can be set at queue time` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops + $Assert.HasField($TargetObject, "enforceSettableVar", $true) + $Assert.HasFieldValue($TargetObject, "enforceSettableVar", $true) +} + +# Synopsis: Job authorization scope for non-release pipelines should be limited in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeNonRelease' ` + -Ref 'ADO-OPS-003' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Limiting job authorization scope for non-release pipelines in organization settings reduces the risk of unauthorized access to resources. + Reason 'Job authorization scope for non-release pipelines is not limited in organization settings.' + Recommend 'Enable `Limit job authorization scope` for non-release pipelines in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops + $Assert.HasField($TargetObject, "enforceJobAuthScope", $true) + $Assert.HasFieldValue($TargetObject, "enforceJobAuthScope", $true) +} + +# Synopsis: Job authorization scope for release pipelines should be limited in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeRelease' ` + -Ref 'ADO-OPS-004' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Limiting job authorization scope for release pipelines in organization settings reduces the risk of unauthorized access to resources. + Reason 'Job authorization scope for release pipelines is not limited in organization settings.' + Recommend 'Enable `Limit job authorization scope` for release pipelines in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops + $Assert.HasField($TargetObject, "enforceJobAuthScopeForReleases", $true) + $Assert.HasFieldValue($TargetObject, "enforceJobAuthScopeForReleases", $true) +} + +# Synopsis: Access to repositories in YAML pipelines should be protected in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.ProtectRepoAccessInYaml' ` + -Ref 'ADO-OPS-005' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Protecting access to repositories in YAML pipelines in organization settings ensures that only authorized repositories are used. + Reason 'Access to repositories in YAML pipelines is not protected in organization settings.' + Recommend 'Enable `Protect access to repositories in YAML pipelines` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops + $Assert.HasField($TargetObject, "enforceReferencedRepoScopedToken", $true) + $Assert.HasFieldValue($TargetObject, "enforceReferencedRepoScopedToken", $true) +} + +# Synopsis: Creation of classic build pipelines should be disabled in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicBuildPipelines' ` + -Ref 'ADO-OPS-006' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Warning { + # Description: Disabling classic build pipelines in organization settings encourages the use of YAML pipelines, which are more secure and maintainable. + Reason 'Creation of classic build pipelines is enabled in organization settings.' + Recommend 'Enable `Disable creation of classic build pipelines` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops + $Assert.HasField($TargetObject, "disableClassicBuildPipelineCreation", $true) + $Assert.HasFieldValue($TargetObject, "disableClassicBuildPipelineCreation", $true) +} + +# Synopsis: Creation of classic release pipelines should be disabled in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicReleasePipelines' ` + -Ref 'ADO-OPS-007' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Warning { + # Description: Disabling classic release pipelines in organization settings encourages the use of YAML pipelines, which are more secure and maintainable. + Reason 'Creation of classic release pipelines is enabled in organization settings.' + Recommend 'Enable `Disable creation of classic release pipelines` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops + $Assert.HasField($TargetObject, "disableClassicReleasePipelineCreation", $true) + $Assert.HasFieldValue($TargetObject, "disableClassicReleasePipelineCreation", $true) +} + +# Synopsis: Pull requests from forks should be limited in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.LimitPRsFromForks' ` + -Ref 'ADO-OPS-008' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Limiting pull requests from forks in organization settings prevents unauthorized code execution in pipelines. + Reason 'Pull requests from forks are not limited in organization settings.' + Recommend 'Enable `Limit pull requests from forks` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops#protecting-branches-in-forks + $Assert.HasField($TargetObject, "forkProtectionEnabled", $true) + $Assert.HasFieldValue($TargetObject, "forkProtectionEnabled", $true) +} + +# Synopsis: Builds from forks should be disabled in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.DisableBuildsFromForks' ` + -Ref 'ADO-OPS-009' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Warning { + # Description: Disabling builds from forks in organization settings reduces the risk of executing untrusted code. + Reason 'Builds from forks are enabled in organization settings.' + Recommend 'Disable `Allow builds from forks` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops#protecting-branches-in-forks + $Assert.HasField($TargetObject, "buildsEnabledForForks", $true) + $Assert.HasFieldValue($TargetObject, "buildsEnabledForForks", $false) +} + +# Synopsis: Shell task arguments should be sanitized in organization settings +Rule 'Azure.DevOps.Organization.Pipelines.Settings.SanitizeShellTaskArguments' ` + -Ref 'ADO-OPS-010' ` + -Type 'Azure.DevOps.Organization.Pipelines.Settings' ` + -Tag @{ release = 'GA' } ` + -Level Warning { + # Description: Sanitizing shell task arguments in organization settings prevents injection attacks in pipeline scripts. + Reason 'Shell task arguments are not sanitized in organization settings.' + Recommend 'Enable `Sanitize shell task arguments` in Azure DevOps organization settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-overview?view=azure-devops#tasks + $Assert.HasField($TargetObject, "enableShellTasksArgsSanitizing", $true) + $Assert.HasFieldValue($TargetObject, "enableShellTasksArgsSanitizing", $true) +} \ No newline at end of file From deab759c87a5c7d9b29a0fb19b0c5245212f2715 Mon Sep 17 00:00:00 2001 From: gajendra Date: Fri, 18 Apr 2025 18:12:58 +0200 Subject: [PATCH 07/14] Added test files for the organization pipeline settings --- ....Organization.Pipelines.Settings.Tests.ps1 | 111 ++++++ ....Organization.Pipelines.Settings.Tests.ps1 | 359 ++++++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 tests/DevOps.Organization.Pipelines.Settings.Tests.ps1 create mode 100644 tests/Rules.Organization.Pipelines.Settings.Tests.ps1 diff --git a/tests/DevOps.Organization.Pipelines.Settings.Tests.ps1 b/tests/DevOps.Organization.Pipelines.Settings.Tests.ps1 new file mode 100644 index 0000000..ee829ac --- /dev/null +++ b/tests/DevOps.Organization.Pipelines.Settings.Tests.ps1 @@ -0,0 +1,111 @@ +BeforeAll { + $rootPath = $PWD; + Import-Module -Name (Join-Path -Path $rootPath -ChildPath '/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psd1') -Force; +} + +Describe "Functions: DevOps.OrganizationPipelinesSettings" { + Context " Read-AdoOrganizationPipelinesSettings without a connection" { + It " should throw an error" { + { + Disconnect-AzDevOps + Read-AdoOrganizationPipelinesSettings -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + } | Should -Throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" + } + } + Context " Read-AdoOrganizationPipelinesSettings on an organization" { + BeforeAll { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + $settings = Read-AdoOrganizationPipelinesSettings -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + } + + It " should return organization pipeline settings" { + $settings | Should -Not -BeNullOrEmpty + } + + It " should return organization pipeline settings that are of type PSObject" { + $settings | Should -BeOfType [PSCustomObject] + } + } + + Context " Read-AdoOrganizationPipelinesSettings with wrong parameters" { + It " should throw an error with a wrong AccessToken" { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken "wrong-token" + { Read-AdoOrganizationPipelinesSettings -Organization $env:ADO_ORGANIZATION -AccessToken "wrong-token" -ErrorAction Stop } | Should -Throw + } + + It " should throw a 404 error with a wrong organization" { + Connect-AzDevOps -Organization 'wrong-org' -AccessToken $env:ADO_ACCESS_TOKEN + { Read-AdoOrganizationPipelinesSettings -Organization 'wrong-org' -AccessToken $env:ADO_ACCESS_TOKEN -ErrorAction Stop } | Should -Throw + } + } + + Context " Export-AdoOrganizationPipelinesSettings without a connection" { + It " should throw an error" { + { + Disconnect-AzDevOps + Export-AdoOrganizationPipelinesSettings -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN -OutputPath $env:ADO_EXPORT_DIR + } | Should -Throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" + } + } + + Context " Export-AdoOrganizationPipelinesSettings" { + BeforeAll { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + Export-AdoOrganizationPipelinesSettings -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN -OutputPath $env:ADO_EXPORT_DIR + } + + It " should export the pipeline settings to a .ado.json file" { + $file = Get-ChildItem -Path $env:ADO_EXPORT_DIR -Filter 'OrganizationpipelineSettings.ado.json' -Recurse + $file | Should -Not -BeNullOrEmpty + } + + It " should export the pipeline settings as parsable JSON" { + $file = Get-ChildItem -Path $env:ADO_EXPORT_DIR -Filter 'OrganizationpipelineSettings.ado.json' -Recurse + $json = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json + $json | Should -Not -BeNullOrEmpty + } + + It " should export an object with an ObjectType of Azure.DevOps.Organization.Pipelines.Settings" { + $file = Get-ChildItem -Path $env:ADO_EXPORT_DIR -Filter 'OrganizationpipelineSettings.ado.json' -Recurse | Select-Object -First 1 + $json = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json + $json.ObjectType | Should -Be "Azure.DevOps.Organization.Pipelines.Settings" + } + } + + Context " Export-AdoOrganizationPipelinesSettings -PassThru" { + BeforeAll { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + $settings = Export-AdoOrganizationPipelinesSettings -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN -PassThru + $ruleResult = $settings | Invoke-PSRule -Module @('PSRule.Rules.AzureDevOps') -Culture en + } + + It " should return organization pipeline settings" { + $settings | Should -Not -BeNullOrEmpty + } + + It " should return organization pipeline settings that are of type PSObject" { + $settings | Should -BeOfType [PSCustomObject] + } + + It " should return organization pipeline settings with an ObjectType of Azure.DevOps.Organization.Pipelines.Settings" { + $settings.ObjectType | Should -Be "Azure.DevOps.Organization.Pipelines.Settings" + } + + It " should return organization pipeline settings with an ObjectName of {Organization}.OrganizationPipelineSettings" { + $settings.ObjectName | Should -Be ("{0}.OrganizationPipelineSettings" -f $env:ADO_ORGANIZATION) + } + + It " The output should have results with Invoke-PSRule" { + $ruleResult | Should -Not -BeNullOrEmpty + } + + It " The output should have results with Invoke-PSRule that are of type [PSRule.Rules.RuleRecord]" { + $ruleResult[0] | Should -BeOfType [PSRule.Rules.RuleRecord] + } + } +} + +AfterAll { + Disconnect-AzDevOps + Remove-Module -Name PSRule.Rules.AzureDevOps -Force; +} \ No newline at end of file diff --git a/tests/Rules.Organization.Pipelines.Settings.Tests.ps1 b/tests/Rules.Organization.Pipelines.Settings.Tests.ps1 new file mode 100644 index 0000000..a760a28 --- /dev/null +++ b/tests/Rules.Organization.Pipelines.Settings.Tests.ps1 @@ -0,0 +1,359 @@ +BeforeAll { + # Setup error handling + $ErrorActionPreference = 'Stop'; + Set-StrictMode -Version latest; + + if ($Env:SYSTEM_DEBUG -eq 'true') { + $VerbosePreference = 'Continue'; + } + + # Setup tests paths + $rootPath = $env:GITHUB_WORKSPACE + if (-not $rootPath) { + Write-Warning "GITHUB_WORKSPACE not set. Using current directory." + $rootPath = $PSScriptRoot + } + $ourModule = (Join-Path -Path $rootPath -ChildPath '/src/PSRule.Rules.AzureDevOps') + + Write-Verbose "Loading module from $ourModule" + Import-Module -Name $ourModule -Force -ErrorAction Stop + $here = (Resolve-Path $PSScriptRoot).Path + + # Get temporary test output folders and store paths + $outPath = Get-Item -Path (Join-Path -Path $here -ChildPath 'out') -ErrorAction SilentlyContinue + if (-not $outPath) { throw "Directory 'out' not found in $here" } + $outPath = $outPath.FullName + $outPathReadOnly = Get-Item -Path (Join-Path -Path $here -ChildPath 'outReadOnly') -ErrorAction SilentlyContinue + if (-not $outPathReadOnly) { throw "Directory 'outReadOnly' not found in $here" } + $outPathReadOnly = $outPathReadOnly.FullName + $outPathFineGrained = Get-Item -Path (Join-Path -Path $here -ChildPath 'outFineGrained') -ErrorAction SilentlyContinue + if (-not $outPathFineGrained) { throw "Directory 'outFineGrained' not found in $here" } + $outPathFineGrained = $outPathFineGrained.FullName + + # Verify OrganizationpipelineSettings.ado.json exists in each directory + $jsonFile = 'OrganizationpipelineSettings.ado.json' + foreach ($path in @($outPath, $outPathReadOnly, $outPathFineGrained)) { + $jsonPath = Join-Path -Path $path -ChildPath $jsonFile + if (-not (Test-Path -Path $jsonPath)) { + throw "JSON file $jsonFile not found in $path" + } + # Validate JSON content + try { + $jsonContent = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json + if ($jsonContent.ObjectType -ne 'Azure.DevOps.Organization.Pipelines.Settings') { + throw "Invalid ObjectType in $jsonPath. Expected 'Azure.DevOps.Organization.Pipelines.Settings', found '$($jsonContent.ObjectType)'" + } + } + catch { + throw "Failed to parse JSON in $jsonPath : $($_.Exception.Message)" + } + } + + # Run rules with default token type + Write-Verbose "Running PSRule for default token type in $outPath" + $ruleResult = Invoke-PSRule -InputPath "$($outPath)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -ErrorAction Stop + + # Run rules with the public baseline + Write-Verbose "Running PSRule for public baseline in $outPath" + $ruleResultPublic = Invoke-PSRule -InputPath "$($outPath)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -Baseline Baseline.PublicProject -ErrorAction Stop + + # Run rules with ReadOnly token type + Write-Verbose "Running PSRule for ReadOnly token type in $outPathReadOnly" + $ruleResultReadOnly = Invoke-PSRule -InputPath "$($outPathReadOnly)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -ErrorAction Stop + + # Run rules with FineGrained token type + Write-Verbose "Running PSRule for FineGrained token type in $outPathFineGrained" + $ruleResultFineGrained = Invoke-PSRule -InputPath "$($outPathFineGrained)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -ErrorAction Stop +} + +Describe "Azure.DevOps.Organization.Pipelines.Settings rules" { + Context 'Rule Loading' { + It ' should load all 10 organization pipeline settings rules' { + $rules = Get-PSRule -Module PSRule.Rules.AzureDevOps + $orgRules = $rules | Where-Object { $_.Name -like 'Azure.DevOps.Organization.Pipelines.Settings.*' } + $orgRules.Count | Should -Be 10 + $orgRules | ForEach-Object { Write-Verbose "Loaded rule: $($_.Name)" } + } + } + + Context 'JSON Input Validation' { + It ' should have valid pipelineSettings.ado.json in out directory' { + $jsonPath = Join-Path -Path $outPath -ChildPath 'OrganizationpipelineSettings.ado.json' + $jsonContent = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json + $jsonContent.ObjectType | Should -Be 'Azure.DevOps.Organization.Pipelines.Settings' + $jsonContent | Should -HaveProperty 'statusBadgesArePrivate', 'enforceSettableVar', 'enforceJobAuthScope', 'enforceJobAuthScopeForReleases', 'enforceReferencedRepoScopedToken', 'disableClassicBuildPipelineCreation', 'disableClassicReleasePipelineCreation', 'forkProtectionEnabled', 'buildsEnabledForForks', 'enableShellTasksArgsSanitizing' + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.DisableAnonymousBadgeAccess' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableAnonymousBadgeAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisableAnonymousBadgeAccess" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableAnonymousBadgeAccess' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableAnonymousBadgeAccess' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.DisableAnonymousBadgeAccess.md') + $fileExists | Should -Be $true + } + + It ' should not be present in the PublicProject baseline' { + $ruleHits = @($ruleResultPublic | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableAnonymousBadgeAccess' }) + $ruleHits.Count | Should -Be 0 + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.LimitSettableVariables' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitSettableVariables' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for LimitSettableVariables" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitSettableVariables' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitSettableVariables' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.LimitSettableVariables.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeNonRelease' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeNonRelease' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for LimitJobAuthScopeNonRelease" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeNonRelease' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeNonRelease' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeNonRelease.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeRelease' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeRelease' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for LimitJobAuthScopeRelease" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeRelease' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeRelease' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.LimitJobAuthScopeRelease.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.ProtectRepoAccessInYaml' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.ProtectRepoAccessInYaml' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for ProtectRepoAccessInYaml" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.ProtectRepoAccessInYaml' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.ProtectRepoAccessInYaml' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.ProtectRepoAccessInYaml.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.DisableClassicBuildPipelines' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicBuildPipelines' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisableClassicBuildPipelines" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicBuildPipelines' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicBuildPipelines' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.DisableClassicBuildPipelines.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.DisableClassicReleasePipelines' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicReleasePipelines' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisableClassicReleasePipelines" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicReleasePipelines' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableClassicReleasePipelines' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.DisableClassicReleasePipelines.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.LimitPRsFromForks' { + It ' should Pass' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitPRsFromForks' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for LimitPRsFromForks" + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitPRsFromForks' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.LimitPRsFromForks' }) + $ruleHits[0].Outcome | Should -Be 'Pass' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.LimitPRsFromForks.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.DisableBuildsFromForks' { + It ' should Fail' { + # Note: This rule fails because buildsEnabledForForks is true in the organization settings. + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableBuildsFromForks' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisableBuildsFromForks" + $ruleHits[0].Outcome | Should -Be 'Fail' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableBuildsFromForks' }) + $ruleHits[0].Outcome | Should -Be 'Fail' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.DisableBuildsFromForks' }) + $ruleHits[0].Outcome | Should -Be 'Fail' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.DisableBuildsFromForks.md') + $fileExists | Should -Be $true + } + } + + Context ' Azure.DevOps.Organization.Pipelines.Settings.SanitizeShellTaskArguments' { + It ' should Fail' { + # Note: This rule fails because enableShellTasksArgsSanitizing is false in the organization settings. + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.SanitizeShellTaskArguments' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for SanitizeShellTaskArguments" + $ruleHits[0].Outcome | Should -Be 'Fail' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.SanitizeShellTaskArguments' }) + $ruleHits[0].Outcome | Should -Be 'Fail' + $ruleHits.Count | Should -Be 1 + } + + It ' should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Pipelines.Settings.SanitizeShellTaskArguments' }) + $ruleHits[0].Outcome | Should -Be 'Fail' + $ruleHits.Count | Should -Be 1 + } + + It ' should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Pipelines.Settings.SanitizeShellTaskArguments.md') + $fileExists | Should -Be $true + } + } +} + +AfterAll { + # Remove Module + Remove-Module -Name PSRule.Rules.AzureDevOps -Force; +} \ No newline at end of file From 31e74a70a6c67d223a9b61f73cf010c1e85d0a79 Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 24 Apr 2025 14:30:02 +0200 Subject: [PATCH 08/14] Created and tested the default license type script file --- src/reports/DefaultLicenseType.ps1 | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/reports/DefaultLicenseType.ps1 diff --git a/src/reports/DefaultLicenseType.ps1 b/src/reports/DefaultLicenseType.ps1 new file mode 100644 index 0000000..5e4e7b8 --- /dev/null +++ b/src/reports/DefaultLicenseType.ps1 @@ -0,0 +1,102 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Retrieves the default license type for new users in an Azure DevOps organization. + + .DESCRIPTION + Queries the Azure DevOps billing API to determine the default license type assigned to new users (e.g., Basic, Stakeholder) and exports the result to an Excel file. + + .PARAMETER Organization + The name of the Azure DevOps organization. + + .PARAMETER OrganizationId + The GUID of the Azure DevOps organization. + + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\DefaultLicenseType.ps1 -Organization "myOrg" -OrganizationId "14b34e2d-2a98-4463-950d-b4d864ad3d2c" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $Organization, + + [Parameter(Mandatory)] + [System.String] $OrganizationId, + + [Parameter(Mandatory)] + [System.String] $PersonalAccessToken, + + [Parameter(Mandatory)] + [System.String] $PatTokenOwnerName +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch default license type +try { + # Construct API endpoint for default license type + [System.String] $licenseUri = "https://azdevopscommerce.dev.azure.com/$OrganizationId/_apis/AzComm/DefaultLicenseType?api-version=7.1-preview.1" + $licenseResponse = Invoke-RestMethod -Uri $licenseUri -Headers $headers -Method Get + Write-Debug "Raw API Response: $($licenseResponse | ConvertTo-Json -Depth 3)" + + # Handle both numeric and string license types + [System.String] $licenseValue = $licenseResponse.defaultLicenseType + [System.Int32] $licenseId = 0 + [System.String] $licenseName = "" + + switch ($licenseValue) { + { $_ -eq 2 -or $_ -eq "basic" } { + $licenseId = 2 + $licenseName = "Basic" + } + { $_ -eq 5 -or $_ -eq "stakeholder" } { + $licenseId = 5 + $licenseName = "Stakeholder" + } + default { + $licenseId = -1 + $licenseName = "Unknown ($licenseValue)" + Write-Warning "Unexpected license type value: [$licenseValue]" + } + } + + Write-Host "Default license type for organization [$Organization]: [$licenseName]" -ForegroundColor Cyan + + # Add result to report + $report += [PSCustomObject]@{ + Organization = $Organization + LicenseTypeId = $licenseId + LicenseTypeName = $licenseName + } +} +catch { + Write-Host "Error Details: $($_.Exception.Response.Content)" -ForegroundColor Red + Write-Error "Failed to fetch default license type: [$($_.Exception.Message)]" + exit +} + +# Exit if no license type found (should not occur, but included for consistency) +if ($report.Count -eq 0) { + Write-Host "[No default license type found.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "DefaultLicenseTypeReport.xlsx" -WorksheetName "DefaultLicenseType" \ No newline at end of file From 2e38777490c1e41538a8dc3e7f29f0d6b6bd33fe Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 24 Apr 2025 14:31:21 +0200 Subject: [PATCH 09/14] Modified the base Directory to a parameter --- src/helper-functions/Export-ToExcel.ps1 | 28 ++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/helper-functions/Export-ToExcel.ps1 b/src/helper-functions/Export-ToExcel.ps1 index 0dd4c95..fe76d5e 100644 --- a/src/helper-functions/Export-ToExcel.ps1 +++ b/src/helper-functions/Export-ToExcel.ps1 @@ -5,20 +5,23 @@ Exports a report to an Excel file with predefined formatting. .DESCRIPTION - Exports the provided report data to an Excel file in the C:\Temp directory with formatting options + Exports the provided report data to an Excel file in the specified directory with formatting options such as autosizing columns, freezing the top row, and bolding the top row. .PARAMETER Report The data to export to Excel, typically an array of PSObjects. .PARAMETER ExportFileName - The name of the Excel file (e.g., "Report.xlsx"). The file will be saved in C:\Temp. + The name of the Excel file (e.g., "Report.xlsx"). The file will be saved in the specified BaseDirectory. .PARAMETER WorksheetName The name of the worksheet in the Excel file. + .PARAMETER BaseDirectory + The directory where the Excel file will be saved. Defaults to "C:\Temp". + .EXAMPLE - Export-ToExcel -Report $reportData -ExportFileName "Report.xlsx" -WorksheetName "ReportData" + Export-ToExcel -Report $reportData -ExportFileName "Report.xlsx" -WorksheetName "ReportData" -BaseDirectory "C:\Reports" #> function Export-ToExcel { @@ -30,17 +33,18 @@ function Export-ToExcel { [System.String] $ExportFileName, [Parameter(Mandatory)] - [System.String] $WorksheetName + [System.String] $WorksheetName, + + [Parameter()] + [System.String] $BaseDirectory = "C:\Temp" ) - # Define the base directory for all exports - [System.String] $baseDirectory = "C:\Temp" # Construct the full export path - [System.String] $exportPath = Join-Path -Path $baseDirectory -ChildPath $ExportFileName + [System.String] $exportPath = Join-Path -Path $BaseDirectory -ChildPath $ExportFileName # Verify output directory - if (-not (Test-Path -Path $baseDirectory)) { - Write-Host "Directory [$baseDirectory] does not exist. It will be created during export." -ForegroundColor Yellow + if (-not (Test-Path -Path $BaseDirectory)) { + Write-Host "Directory [$BaseDirectory] does not exist. It will be created during export." -ForegroundColor Yellow } # Define Excel export parameters @@ -54,9 +58,9 @@ function Export-ToExcel { try { # Ensure output directory exists - if (-not (Test-Path -Path $baseDirectory)) { - New-Item -Path $baseDirectory -ItemType Directory -Force | Out-Null - Write-Host "Created directory: [$baseDirectory]" -ForegroundColor Green + if (-not (Test-Path -Path $BaseDirectory)) { + New-Item -Path $BaseDirectory -ItemType Directory -Force | Out-Null + Write-Host "Created directory: [$BaseDirectory]" -ForegroundColor Green } # Export data to Excel From 827a58e3dec6e92926a9d66ce416b8e1870f40d7 Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 24 Apr 2025 14:46:58 +0200 Subject: [PATCH 10/14] Created the default license type and modifed the base directory to parameter --- .../DevOps.OrganizationSecurityPolicies.ps1 | 200 +++++++++ ...ps.Organization.Security.Policies.Rule.ps1 | 141 +++++++ ...s.Organization.Security.Policies.Tests.ps1 | 112 +++++ ...s.Organization.Security.Policies.Tests.ps1 | 395 ++++++++++++++++++ 4 files changed, 848 insertions(+) create mode 100644 src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationSecurityPolicies.ps1 create mode 100644 src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Security.Policies.Rule.ps1 create mode 100644 tests/DevOps.Organization.Security.Policies.Tests.ps1 create mode 100644 tests/Rules.Organization.Security.Policies.Tests.ps1 diff --git a/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationSecurityPolicies.ps1 b/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationSecurityPolicies.ps1 new file mode 100644 index 0000000..bf026d7 --- /dev/null +++ b/src/PSRule.Rules.AzureDevOps/Functions/DevOps.OrganizationSecurityPolicies.ps1 @@ -0,0 +1,200 @@ +<# + .SYNOPSIS + Retrieves and exports Azure DevOps organization-level security policy settings. + + .DESCRIPTION + This script provides functions to query and export organization-level security policy settings from Azure DevOps using an internal, undocumented API endpoint. + The settings can be displayed in the console or exported to a JSON file for further analysis. + + .NOTES + WARNING: This script uses an internal and undocumented API endpoint: + https://dev.azure.com/{org}/_settings/organizationPolicy?__rt=fps&__ver=2 + This endpoint is not part of the officially supported Azure DevOps REST API. + Microsoft may change, deprecate, or remove it at any time without notice. + Use in production scenarios at your own risk and validate regularly. + + + .LINK + https://github.com/PoshCode/PowerShellPracticeAndStyle + https://learn.microsoft.com/en-us/powershell/scripting/developer/cmdlet/strongly-encouraged-development-guidelines +#> + +function Read-AdoOrganizationSecurityPolicies { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Organization, + + [Parameter(Mandatory = $true)] + [string] + $AccessToken + ) + + # Construct the internal policy settings endpoint + $uri = "https://dev.azure.com/$Organization/_settings/organizationPolicy?__rt=fps&__ver=2" + + # Set headers with Bearer token + $headers = @{ + Authorization = "Bearer $AccessToken" + Accept = "application/json" + } + + try { + # Use Invoke-WebRequest to allow content inspection before parsing + $rawResponse = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers -UseBasicParsing + + # Check for HTML content which likely means the token is expired or invalid + if ($rawResponse.Content -match ' +function Export-AdoOrganizationSecurityPolicies { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Organization, + + [Parameter(Mandatory = $true)] + [string] + $AccessToken, + + [Parameter(ParameterSetName = 'JsonFile')] + [string] + $OutputPath, + + [Parameter(ParameterSetName = 'PassThru')] + [switch] + $PassThru + ) + + # Check for active connection + if ($null -eq $script:connection) { + throw 'Not connected to Azure DevOps. Run Connect-AzDevOps first.' + } + + # Retrieve security policy settings + try { + $settings = Read-AdoOrganizationSecurityPolicies -Organization $Organization -AccessToken $AccessToken + if ($null -eq $settings) { + throw "No Organization security policy settings returned from Read-AdoOrganizationSecurityPolicies." + } + } + catch { + throw "Failed to get Organization security policy settings from Azure DevOps: $($_.Exception.Message)" + } + + # Process settings into exportable format + $settingsDetails = @() + $settingsObject = [PSCustomObject]$settings + + # Add metadata properties + $settingsObject | Add-Member -MemberType NoteProperty -Name ObjectType -Value 'Azure.DevOps.Organization.Security.Policies' -Force + $settingsObject | Add-Member -MemberType NoteProperty -Name ObjectName -Value "$($script:connection.Organization).OrganizationSecurityPolicies" -Force + $settingsObject | Add-Member -MemberType NoteProperty -Name name -Value "OrganizationSecurityPolicies" -Force + + # Create structured id object + $id = @{ + originalId = $null + resourceName = "OrganizationSecurityPolicies" + organization = $script:connection.Organization + } | ConvertTo-Json -Depth 100 + $settingsObject | Add-Member -MemberType NoteProperty -Name id -Value $id -Force + + $settingsDetails += $settingsObject + + # Output based on parameters + if ($PassThru) { + Write-Output $settingsDetails + } + else { + $settingsDetails | ConvertTo-Json -Depth 100 | Out-File -FilePath "$OutputPath\OrganizationSecurityPolicies.ado.json" + } +} + +Export-ModuleMember -Function Export-AdoOrganizationSecurityPolicies \ No newline at end of file diff --git a/src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Security.Policies.Rule.ps1 b/src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Security.Policies.Rule.ps1 new file mode 100644 index 0000000..2a69ba8 --- /dev/null +++ b/src/PSRule.Rules.AzureDevOps/rules/AzureDevOps.Organization.Security.Policies.Rule.ps1 @@ -0,0 +1,141 @@ +# Azure DevOps organization security policy rules + +# Synopsis: Secure Shell (SSH) authentication should be disallowed +Rule 'Azure.DevOps.Organization.Security.Policies.DisallowSecureShell' ` + -Ref 'ADO-OSP-001' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Disallowing Secure Shell (SSH) authentication enhances security by limiting authentication methods to more controlled mechanisms. + Reason 'Secure Shell (SSH) authentication is allowed.' + Recommend 'Disabla `Secure Shell (SSH) authentication` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.DisallowSecureShell", $true) + $Assert.HasFieldValue($TargetObject, "policy.DisallowSecureShell", $true) +} + +# Synopsis: External package protection token for artifacts should be enabled +Rule 'Azure.DevOps.Organization.Security.Policies.ArtifactsExternalPackageProtectionToken' ` + -Ref 'ADO-OSP-002' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Enabling external package protection tokens for artifacts ensures secure access to external packages. + Reason 'External package protection token for artifacts is disabled.' + Recommend 'Enable `Artifacts external package protection token` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.ArtifactsExternalPackageProtectionToken", $true) + $Assert.HasFieldValue($TargetObject, "policy.ArtifactsExternalPackageProtectionToken", $true) +} + +# Synopsis: Azure Active Directory (AAD) guest user access should be disallowed +Rule 'Azure.DevOps.Organization.Security.Policies.DisallowAadGuestUserAccess' ` + -Ref 'ADO-OSP-003' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Disallowing AAD guest user access prevents external users from accessing organization resources, reducing security risks. + Reason 'AAD guest user access is allowed.' + Recommend 'Disable `External guest access` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.DisallowAadGuestUserAccess", $true) + $Assert.HasFieldValue($TargetObject, "policy.DisallowAadGuestUserAccess", $true) +} + +# Synopsis: Anonymous access to the organization should be disallowed +Rule 'Azure.DevOps.Organization.Security.Policies.DisallowAnonymousAccess' ` + -Ref 'ADO-OSP-004' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Disallowing anonymous access prevents unauthorized users from accessing organization resources. + Reason 'Anonymous access to the organization is allowed.' + Recommend 'Disable `Allow anonymous access` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.AllowAnonymousAccess", $true) + $Assert.HasFieldValue($TargetObject, "policy.AllowAnonymousAccess", $false) +} + +# Synopsis: Team admins should not be allowed to send invitations using access token +Rule 'Azure.DevOps.Organization.Security.Policies.DisallowTeamAdminsInvitationsAccessToken' ` + -Ref 'ADO-OSP-005' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Disallowing team admins from sending invitations ensures centralized control over user access by organization admins. + Reason 'Team admins are allowed to send invitations using access tokens.' + Recommend 'Disable `Allow team admins invitations access token` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.AllowTeamAdminsInvitationsAccessToken", $true) + $Assert.HasFieldValue($TargetObject, "policy.AllowTeamAdminsInvitationsAccessToken", $false) +} + +# Synopsis: Feedback collection should be disallowed for the organization +Rule 'Azure.DevOps.Organization.Security.Policies.DisallowFeedbackCollection' ` + -Ref 'ADO-OSP-006' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Disallowing feedback collection protects user privacy by preventing data sharing with Microsoft. + Reason 'Feedback collection is allowed for the organization.' + Recommend 'Disable `Allow feedback collection` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.AllowFeedbackCollection", $true) + $Assert.HasFieldValue($TargetObject, "policy.AllowFeedbackCollection", $false) +} + +# Synopsis: Logging of audit events should be enabled +Rule 'Azure.DevOps.Organization.Security.Policies.EnableLogAuditEvents' ` + -Ref 'ADO-OSP-007' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Enabling audit event logging is critical for security monitoring and compliance with regulatory requirements. + Reason 'Logging of audit events is disabled.' + Recommend 'Enable `Log audit events` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.LogAuditEvents", $true) + $Assert.HasFieldValue($TargetObject, "policy.LogAuditEvents", $true) +} + +# Synopsis: Request access token for authentication should be disallowed +Rule 'Azure.DevOps.Organization.Security.Policies.DisallowRequestAccessToken' ` + -Ref 'ADO-OSP-008' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Disallowing request access tokens reduces the risk of unauthorized access requests. + Reason 'Request access token for authentication is allowed.' + Recommend 'Disable `Allow request access token` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.AllowRequestAccessToken", $true) + $Assert.HasFieldValue($TargetObject, "policy.AllowRequestAccessToken", $false) +} + +# Synopsis: OAuth authentication should be allowed +Rule 'Azure.DevOps.Organization.Security.Policies.AllowOAuthAuthentication' ` + -Ref 'ADO-OSP-009' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Allowing OAuth authentication supports integration with external services while maintaining security. + Reason 'OAuth authentication is disallowed.' + Recommend 'Disable `Disallow OAuth authentication` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.DisallowOAuthAuthentication", $true) + $Assert.HasFieldValue($TargetObject, "policy.DisallowOAuthAuthentication", $false) +} + +# Synopsis: AAD conditional access enforcement should be enabled +Rule 'Azure.DevOps.Organization.Security.Policies.EnforceAADConditionalAccess' ` + -Ref 'ADO-OSP-010' ` + -Type 'Azure.DevOps.Organization.Security.Policies' ` + -Tag @{ release = 'GA' } ` + -Level Error { + # Description: Enforcing AAD conditional access applies multifactor authentication and other security policies, enhancing authentication security. + Reason 'AAD conditional access enforcement is disabled.' + Recommend 'Enable `Enforce AAD conditional access` in Azure DevOps organization security policy settings.' + # Links: https://learn.microsoft.com/en-us/azure/devops/organizations/security/security-best-practices?view=azure-devops + $Assert.HasField($TargetObject, "policy.EnforceAADConditionalAccess", $true) + $Assert.HasFieldValue($TargetObject, "policy.EnforceAADConditionalAccess", $true) +} \ No newline at end of file diff --git a/tests/DevOps.Organization.Security.Policies.Tests.ps1 b/tests/DevOps.Organization.Security.Policies.Tests.ps1 new file mode 100644 index 0000000..2af063a --- /dev/null +++ b/tests/DevOps.Organization.Security.Policies.Tests.ps1 @@ -0,0 +1,112 @@ +BeforeAll { + $rootPath = $PWD; + Import-Module -Name (Join-Path -Path $rootPath -ChildPath '/src/PSRule.Rules.AzureDevOps/PSRule.Rules.AzureDevOps.psd1') -Force; +} + +Describe "Functions: DevOps.OrganizationSecurityPolicies" { + Context " Read-AdoOrganizationSecurityPolicies without a connection" { + It " should throw an error" { + { + Disconnect-AzDevOps + Read-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + } | Should -Throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" + } + } + + Context " Read-AdoOrganizationSecurityPolicies on an organization" { + BeforeAll { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + $policies = Read-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + } + + It " should return organization security policies" { + $policies | Should -Not -BeNullOrEmpty + } + + It " should return organization security policies that are of type PSObject" { + $policies | Should -BeOfType [PSCustomObject] + } + } + + Context " Read-AdoOrganizationSecurityPolicies with wrong parameters" { + It " should throw an error with a wrong AccessToken" { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken "wrong-token" + { Read-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken "wrong-token" -ErrorAction Stop } | Should -Throw + } + + It " should throw a 404 error with a wrong organization" { + Connect-AzDevOps -Organization 'wrong-org' -AccessToken $env:ADO_ACCESS_TOKEN + { Read-AdoOrganizationSecurityPolicies -Organization 'wrong-org' -AccessToken $env:ADO_ACCESS_TOKEN -ErrorAction Stop } | Should -Throw + } + } + + Context " Export-AdoOrganizationSecurityPolicies without a connection" { + It " should throw an error" { + { + Disconnect-AzDevOps + Export-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN -OutputPath $env:ADO_EXPORT_DIR + } | Should -Throw "Not connected to Azure DevOps. Run Connect-AzDevOps first" + } + } + + Context " Export-AdoOrganizationSecurityPolicies" { + BeforeAll { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + Export-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN -OutputPath $env:ADO_EXPORT_DIR + } + + It " should export the security policies to a .ado.json file" { + $file = Get-ChildItem -Path $env:ADO_EXPORT_DIR -Filter 'OrganizationSecurityPolicies.ado.json' -Recurse + $file | Should -Not -BeNullOrEmpty + } + + It " should export the security policies as parsable JSON" { + $file = Get-ChildItem -Path $env:ADO_EXPORT_DIR -Filter 'OrganizationSecurityPolicies.ado.json' -Recurse + $json = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json + $json | Should -Not -BeNullOrEmpty + } + + It " should export an object with an ObjectType of Azure.DevOps.Organization.Security.Policies" { + $file = Get-ChildItem -Path $env:ADO_EXPORT_DIR -Filter 'OrganizationSecurityPolicies.ado.json' -Recurse | Select subj-Object -First 1 + $json = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json + $json.ObjectType | Should -Be "Azure.DevOps.Organization.Security.Policies" + } + } + + Context " Export-AdoOrganizationSecurityPolicies -PassThru" { + BeforeAll { + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + $policies = Export-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN -PassThru + $ruleResult = $policies | Invoke-PSRule -Module @('PSRule.Rules.AzureDevOps') -Culture en + } + + It " should return organization security policies" { + $policies | Should -Not -BeNullOrEmpty + } + + It " should return organization security policies that are of type PSObject" { + $policies | Should -BeOfType [PSCustomObject] + } + + It " should return organization security policies with an ObjectType of Azure.DevOps.Organization.Security.Policies" { + $policies.ObjectType | Should -Be "Azure.DevOps.Organization.Security.Policies" + } + + It " should return organization security policies with an ObjectName of {Organization}.OrganizationSecurityPolicies" { + $policies.ObjectName | Should -Be ("{0}.OrganizationSecurityPolicies" -f $env:ADO_ORGANIZATION) + } + + It " The output should have results with Invoke-PSRule" { + $ruleResult | Should -Not -BeNullOrEmpty + } + + It " The output should have results with Invoke-PSRule that are of type [PSRule.Rules.RuleRecord]" { + $ruleResult[0] | Should -BeOfType [PSRule.Rules.RuleRecord] + } + } +} + +AfterAll { + Disconnect-AzDevOps + Remove-Module -Name PSRule.Rules.AzureDevOps -Force; +} \ No newline at end of file diff --git a/tests/Rules.Organization.Security.Policies.Tests.ps1 b/tests/Rules.Organization.Security.Policies.Tests.ps1 new file mode 100644 index 0000000..3efa24e --- /dev/null +++ b/tests/Rules.Organization.Security.Policies.Tests.ps1 @@ -0,0 +1,395 @@ + +BeforeAll { + # Setup error handling + $ErrorActionPreference = 'Stop'; + Set-StrictMode -Version latest; + + if ($Env:SYSTEM_DEBUG -eq 'true') { + $VerbosePreference = 'Continue'; + } + + # Setup tests paths + $rootPath = $env:GITHUB_WORKSPACE + if (-not $rootPath) { + Write-Warning "GITHUB_WORKSPACE not set. Using current directory." + $rootPath = $PSScriptRoot + } + $ourModule = (Join-Path -Path $rootPath -ChildPath '/src/PSRule.Rules.AzureDevOps') + + Write-Verbose "Loading module from $ourModule" + Import-Module -Name $ourModule -Force -ErrorAction Stop + $here = (Resolve-Path $PSScriptRoot).Path + + # Get temporary test output folders and store paths + $outPath = Get-Item -Path (Join-Path -Path $here -ChildPath 'out') -ErrorAction SilentlyContinue + if (-not $outPath) { throw "Directory 'out' not found in $here" } + $outPath = $outPath.FullName + $outPathReadOnly = Get-Item -Path (Join-Path -Path $here -ChildPath 'outReadOnly') -ErrorAction SilentlyContinue + if (-not $outPathReadOnly) { throw "Directory 'outReadOnly' not found in $here" } + $outPathReadOnly = $outPathReadOnly.FullName + $outPathFineGrained = Get-Item -Path (Join-Path -Path $here -ChildPath 'outFineGrained') -ErrorAction SilentlyContinue + if (-not $outPathFineGrained) { throw "Directory 'outFineGrained' not found in $here" } + $outPathFineGrained = $outPathFineGrained.FullName + + # Verify environment variables + if (-not $env:ADO_ORGANIZATION) { throw "Environment variable ADO_ORGANIZATION not set" } + if (-not $env:ADO_ACCESS_TOKEN) { throw "Environment variable ADO_ACCESS_TOKEN not set" } + if (-not $env:ADO_ACCESS_TOKEN_READONLY) { throw "Environment variable ADO_ACCESS_TOKEN_READONLY not set" } + if (-not $env:ADO_ACCESS_TOKEN_FINEGRAINED) { throw "Environment variable ADO_ACCESS_TOKEN_FINEGRAINED not set" } + if (-not $env:ADO_EXPORT_DIR) { throw "Environment variable ADO_EXPORT_DIR not set" } + if (-not $env:ADO_EXPORT_DIR_READONLY) { throw "Environment variable ADO_EXPORT_DIR_READONLY not set" } + if (-not $env:ADO_EXPORT_DIR_FINEGRAINED) { throw "Environment variable ADO_EXPORT_DIR_FINEGRAINED not set" } + + # Generate fresh JSON files for each token type + Write-Verbose "Generating fresh OrganizationSecurityPolicies.ado.json files" + Connect-AzDevOps -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN + Export-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN -OutputPath $env:ADO_EXPORT_DIR + Export-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN_READONLY -OutputPath $env:ADO_EXPORT_DIR_READONLY + Export-AdoOrganizationSecurityPolicies -Organization $env:ADO_ORGANIZATION -AccessToken $env:ADO_ACCESS_TOKEN_FINEGRAINED -OutputPath $env:ADO_EXPORT_DIR_FINEGRAINED + + # Verify OrganizationSecurityPolicies.ado.json exists in each directory + $jsonFile = 'OrganizationSecurityPolicies.ado.json' + foreach ($path in @($outPath, $outPathReadOnly, $outPathFineGrained)) { + $jsonPath = Join-Path -Path $path -ChildPath $jsonFile + if (-not (Test-Path -Path $jsonPath)) { + throw "JSON file $jsonFile not found in $path after export" + } + # Validate JSON content + try { + $jsonContent = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json + if ($jsonContent.ObjectType -ne 'Azure.DevOps.Organization.Security.Policies') { + throw "Invalid ObjectType in $jsonPath. Expected 'Azure.DevOps.Organization.Security.Policies', found '$($jsonContent.ObjectType)'" + } + } + catch { + throw "Failed to parse JSON in $jsonPath : $($_.Exception.Message)" + } + } + + # Run rules with default token type + Write-Verbose "Running PSRule for default token type in $outPath" + $ruleResult = Invoke-PSRule -InputPath "$($outPath)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -ErrorAction Stop + + # Run rules with the public baseline + Write-Verbose "Running PSRule for public baseline in $outPath" + $ruleResultPublic = Invoke-PSRule -InputPath "$($outPath)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -Baseline Baseline.PublicProject -ErrorAction Stop + + # Run rules with ReadOnly token type + Write-Verbose "Running PSRule for ReadOnly token type in $outPathReadOnly" + $ruleResultReadOnly = Invoke-PSRule -InputPath "$($outPathReadOnly)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -ErrorAction Stop + + # Run rules with FineGrained token type + Write-Verbose "Running PSRule for FineGrained token type in $outPathFineGrained" + $ruleResultFineGrained = Invoke-PSRule -InputPath "$($outPathFineGrained)/" -Module PSRule.Rules.AzureDevOps -Format Detect -Culture en -ErrorAction Stop +} + +Describe "Azure.DevOps.Organization.Security.Policies rules" { + Context 'Rule Loading' { + It 'should load all 10 organization security policies rules' { + $rules = Get-PSRule -Module PSRule.Rules.AzureDevOps + $orgRules = $rules | Where-Object { $_.Name -like 'Azure.DevOps.Organization.Security.Policies.*' } + $orgRules.Count | Should -Be 10 + $orgRules | ForEach-Object { Write-Verbose "Loaded rule: $($_.Name)" } + } + } + + Context 'JSON Input Validation' { + It 'should have valid OrganizationSecurityPolicies.ado.json in out directory' { + $jsonPath = Join-Path -Path $outPath -ChildPath 'OrganizationSecurityPolicies.ado.json' + $jsonContent = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json + $jsonContent.ObjectType | Should -Be 'Azure.DevOps.Organization.Security.Policies' + $jsonContent | Should -HaveProperty 'disallowSecureShell', 'artifactsExternalPackageProtectionToken', 'disallowAadGuestUserAccess', 'allowAnonymousAccess', 'allowTeamAdminsInvitationsAccessToken', 'allowFeedbackCollection', 'logAuditEvents', 'allowRequestAccessToken', 'disallowOAuthAuthentication', 'enforceAADConditionalAccess' + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.DisallowSecureShell' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowSecureShell' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisallowSecureShell" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowSecureShell' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowSecureShell' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.DisallowSecureShell.md') + $fileExists | Should -Be $true + } + + It 'should not be present in the PublicProject baseline' { + $ruleHits = @($ruleResultPublic | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowSecureShell' }) + $ruleHits.Count | Should -Be 0 + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.ArtifactsExternalPackageProtectionToken' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.ArtifactsExternalPackageProtectionToken' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for ArtifactsExternalPackageProtectionToken" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.ArtifactsExternalPackageProtectionToken' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.ArtifactsExternalPackageProtectionToken' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.ArtifactsExternalPackageProtectionToken.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.DisallowAadGuestUserAccess' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowAadGuestUserAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisallowAadGuestUserAccess" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowAadGuestUserAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowAadGuestUserAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.DisallowAadGuestUserAccess.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.DisallowAnonymousAccess' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowAnonymousAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisallowAnonymousAccess" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowAnonymousAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowAnonymousAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.DisallowAnonymousAccess.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.DisallowTeamAdminsInvitationsAccessToken' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowTeamAdminsInvitationsAccessToken' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisallowTeamAdminsInvitationsAccessToken" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowTeamAdminsInvitationsAccessToken' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowTeamAdminsInvitationsAccessToken' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.DisallowTeamAdminsInvitationsAccessToken.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.DisallowFeedbackCollection' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowFeedbackCollection' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisallowFeedbackCollection" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowFeedbackCollection' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowFeedbackCollection' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.DisallowFeedbackCollection.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.EnableLogAuditEvents' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.EnableLogAuditEvents' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for EnableLogAuditEvents" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.EnableLogAuditEvents' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.EnableLogAuditEvents' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.EnableLogAuditEvents.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.DisallowRequestAccessToken' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowRequestAccessToken' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for DisallowRequestAccessToken" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowRequestAccessToken' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.DisallowRequestAccessToken' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.DisallowRequestAccessToken.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.AllowOAuthAuthentication' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.AllowOAuthAuthentication' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for AllowOAuthAuthentication" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.AllowOAuthAuthentication' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.AllowOAuthAuthentication' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.AllowOAuthAuthentication.md') + $fileExists | Should -Be $true + } + } + + Context 'Azure.DevOps.Organization.Security.Policies.EnforceAADConditionalAccess' { + It 'should process rule' { + $ruleHits = @($ruleResult | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.EnforceAADConditionalAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty -Because "Rule should process for EnforceAADConditionalAccess" + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for ReadOnly TokenType' { + $ruleHits = @($ruleResultReadOnly | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.EnforceAADConditionalAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should be the same for the FineGrained TokenType' { + $ruleHits = @($ruleResultFineGrained | Where-Object { $_.RuleName -eq 'Azure.DevOps.Organization.Security.Policies.EnforceAADConditionalAccess' }) + $ruleHits | Should -Not -BeNullOrEmpty + $ruleHits[0].Outcome | Should -BeIn @('Pass', 'Fail') + $ruleHits.Count | Should -Be 1 + } + + It 'should have an English markdown help file' { + $fileExists = Test-Path -Path (Join-Path -Path $ourModule -ChildPath 'en/Azure.DevOps.Organization.Security.Policies.EnforceAADConditionalAccess.md') + $fileExists | Should -Be $true + } + } +} + +AfterAll { + # Remove Module + Disconnect-AzDevOps + Remove-Module -Name PSRule.Rules.AzureDevOps -Force +} \ No newline at end of file From df809d2eb160d7e47ce55ce5cd2c05d73dcee81d Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 24 Apr 2025 15:05:30 +0200 Subject: [PATCH 11/14] Modified the get-help --- src/reports/DefaultLicenseType.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/reports/DefaultLicenseType.ps1 b/src/reports/DefaultLicenseType.ps1 index 5e4e7b8..1c9460c 100644 --- a/src/reports/DefaultLicenseType.ps1 +++ b/src/reports/DefaultLicenseType.ps1 @@ -5,13 +5,16 @@ Retrieves the default license type for new users in an Azure DevOps organization. .DESCRIPTION - Queries the Azure DevOps billing API to determine the default license type assigned to new users (e.g., Basic, Stakeholder) and exports the result to an Excel file. + Queries the Azure DevOps billing API to determine the default license type assigned to new users + (e.g., Basic, Stakeholder) and exports the result to an Excel file. .PARAMETER Organization The name of the Azure DevOps organization. .PARAMETER OrganizationId The GUID of the Azure DevOps organization. + This function uses the internal Azure DevOps billing API: + https://azdevopscommerce.dev.azure.com/{orgId}/_apis/AzComm/DefaultLicenseType .PARAMETER PersonalAccessToken The Personal Access Token (PAT) for authenticating with the Azure DevOps API. @@ -20,7 +23,7 @@ The username associated with the PAT token, typically an email. .EXAMPLE - .\DefaultLicenseType.ps1 -Organization "myOrg" -OrganizationId "14b34e2d-2a98-4463-950d-b4d864ad3d2c" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" + .\DefaultLicenseType.ps1 -Organization "myOrg" -OrganizationId "20b34e00-7898-7763-950d-098764ad3d2c" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" #> param ( From 62855e42cc1b8b791fa8d417529621b9597b7d3b Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 24 Apr 2025 16:30:02 +0200 Subject: [PATCH 12/14] Created and tested the Tenant Organization Connections script --- src/reports/TenantOrganizationConnections.ps1 | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/reports/TenantOrganizationConnections.ps1 diff --git a/src/reports/TenantOrganizationConnections.ps1 b/src/reports/TenantOrganizationConnections.ps1 new file mode 100644 index 0000000..b7baa1b --- /dev/null +++ b/src/reports/TenantOrganizationConnections.ps1 @@ -0,0 +1,96 @@ +#Requires -Modules ImportExcel + +<# + .SYNOPSIS + Lists Azure DevOps organizations connected to an Entra ID tenant. + + .DESCRIPTION + Queries the Azure DevOps EnterpriseCatalog API to retrieve a list of organizations connected to the + specified Entra ID tenant and exports the results to an Excel file. + + .PARAMETER TenantId + The Entra ID (Azure AD) tenant ID used to scope the query. + API: https://aexprodweu1.vsaex.visualstudio.com/_apis/EnterpriseCatalog/Organizations?tenantId={tenantId} + + .PARAMETER PersonalAccessToken + A valid Azure DevOps Bearer token + + .PARAMETER PatTokenOwnerName + The username associated with the PAT token, typically an email. + + .EXAMPLE + .\TenantOrganizationConnections.ps1 -TenantId "a74be31f-7904-4c43-8ef5-c82967c8e559" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" +#> + +param ( + [Parameter(Mandatory)] + [System.String] $TenantId, + + [Parameter(Mandatory)] + [System.String] $AccessToken +) + +# Source the external function to create authentication header +. ".\src\helper-functions\New-AdoAuthenticationHeader.ps1" + +# Source the external function to export to Excel +. ".\src\helper-functions\Export-ToExcel.ps1" + +# Configure headers for HTTP requests to the Azure DevOps API +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -AccessToken $AccessToken + +# Initialize report array +[System.Object[]] $report = @() + +# Fetch organizations connected to the tenant +try { + # Construct API endpoint for organization connections + [System.String] $orgUri = "https://aexprodweu1.vsaex.visualstudio.com/_apis/EnterpriseCatalog/Organizations?tenantId=$TenantId" + $headers['Accept'] = "text/csv" + + # Download CSV to temp file + $tempFile = New-TemporaryFile + Invoke-WebRequest -Uri $orgUri -Headers $headers -OutFile $tempFile -UseBasicParsing + + # Read and normalize CSV + $data = Import-Csv -Path $tempFile | ForEach-Object { + $cleaned = @{} + $_.PSObject.Properties | ForEach-Object { + $key = $_.Name.Trim() + $cleaned[$key] = $_.Value + } + + [PSCustomObject]@{ + OrganizationId = $cleaned['Organization Id'] + OrganizationName = $cleaned['Organization Name'] + Url = $cleaned['Url'] + Owner = $cleaned['Owner'] + ExceptionType = $cleaned['Exception Type'] + ErrorMessage = $cleaned['Error Message'] + } + } + + Write-Host "Found [$($data.Count)] organizations connected to tenant [$TenantId]." -ForegroundColor Cyan + + # Add data to report + $report = $data + + # Clean up temp file + Remove-Item -Path $tempFile -Force +} +catch { + Write-Error "Failed to fetch tenant organization connections: [$($_.Exception.Message)]" + if (Test-Path -Path $tempFile) { + Remove-Item -Path $tempFile -Force + } + exit +} + +# Exit if no organizations found +if ($report.Count -eq 0) { + Write-Host "[No organizations found for tenant.]" -ForegroundColor Yellow + exit +} + +# Call the export function +Export-ToExcel -Report $report -ExportFileName "TenantOrganizationConnectionsReport.xlsx" -WorksheetName "TenantOrganizations" \ No newline at end of file From cce2b34c2018dbaa706b9dc4c120537d3b28423e Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 24 Apr 2025 16:31:42 +0200 Subject: [PATCH 13/14] Modified the header function and default license type script to add bearer token as parameter --- .../New-AdoAuthenticationHeader.ps1 | 77 ++++++++++++------- src/reports/DefaultLicenseType.ps1 | 7 +- 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/src/helper-functions/New-AdoAuthenticationHeader.ps1 b/src/helper-functions/New-AdoAuthenticationHeader.ps1 index dc21d20..070def7 100644 --- a/src/helper-functions/New-AdoAuthenticationHeader.ps1 +++ b/src/helper-functions/New-AdoAuthenticationHeader.ps1 @@ -3,17 +3,25 @@ Creates a well-formatted Azure DevOps authentication header. .DESCRIPTION - This function formats an Azure DevOps Personal Access Token (PAT) into a Basic Authentication header that can be used in REST API calls. - The PAT token owner name (username) is required and should be the username associated with the PAT token, typically an email. + This function formats an Azure DevOps authentication header for REST API calls. It supports either a Personal Access Token (PAT) for Basic Authentication or a Bearer token (e.g., OAuth 2.0 access token from Microsoft Entra ID). For PAT, the PAT token owner name (username) is required, typically an email. For Bearer token, only the token is needed. - .PARAMETER PatToken - The Personal Access Token (PAT) generated in Azure DevOps. + .PARAMETER PersonalAccessToken + The Personal Access Token (PAT) generated in Azure DevOps. Required for PAT-based authentication. Also accepts the alias 'PatToken'. .PARAMETER PatTokenOwnerName - The username associated with the PAT token, typically an email. + The username associated with the PAT token, typically an email. Required for PAT-based authentication. + + .PARAMETER AccessToken + A Bearer token (e.g., OAuth 2.0 access token from Microsoft Entra ID) for Bearer authentication. If provided, PAT parameters are ignored. + + .EXAMPLE + # Using PAT + $adoAuthHeader = New-AdoAuthenticationHeader -PersonalAccessToken "yourPatTokenHere" -PatTokenOwnerName "Ben John" + Invoke-RestMethod -Uri "https://dev.azure.com/organization/_apis/projects" -Headers $adoAuthHeader -Method Get .EXAMPLE - $adoAuthHeader = New-AdoAuthenticationHeader -PatToken "yourPatTokenHere" -PatTokenOwnerName "Ben John" + # Using Bearer token + $adoAuthHeader = New-AdoAuthenticationHeader -AccessToken "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik..." Invoke-RestMethod -Uri "https://dev.azure.com/organization/_apis/projects" -Headers $adoAuthHeader -Method Get #> @@ -21,35 +29,52 @@ function New-AdoAuthenticationHeader { [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( - [Parameter(Mandatory)] + [Parameter(Mandatory = $true, ParameterSetName = 'Pat')] + [ValidateNotNullOrEmpty()] + [Alias('PatToken')] + [System.String] $PersonalAccessToken, + + [Parameter(Mandatory = $true, ParameterSetName = 'Pat')] [ValidateNotNullOrEmpty()] - [System.String] $PatToken, + [System.String] $PatTokenOwnerName, - [Parameter(Mandatory)] + [Parameter(Mandatory = $true, ParameterSetName = 'Bearer')] [ValidateNotNullOrEmpty()] - [System.String] $PatTokenOwnerName + [System.String] $AccessToken ) - # Trim the PAT token to remove any accidental whitespace - $PatToken = $PatToken.Trim() + # Construct the authentication header hashtable + [System.Collections.Hashtable]$adoAuthenticationHeader = @{ + 'Content-Type' = 'application/json' + } - # Trim the PatTokenOwnerName to remove any accidental whitespace - $PatTokenOwnerName = $PatTokenOwnerName.Trim() + switch ($PSCmdlet.ParameterSetName) { + 'Pat' { + # Trim the PAT token and owner name to remove any accidental whitespace + $PersonalAccessToken = $PersonalAccessToken.Trim() + $PatTokenOwnerName = $PatTokenOwnerName.Trim() - # Format the authentication string in the format "username:patToken" - Write-Debug -Message "Combining PatTokenOwnerName and PatToken into a single string: [$PatTokenOwnerName]:[$PatToken]" - $authString = "{0}:{1}" -f $PatTokenOwnerName, $PatToken + # Format the authentication string in the format "username:patToken" + Write-Debug -Message "Combining PatTokenOwnerName and PatToken into a single string: [$PatTokenOwnerName]:[$PersonalAccessToken]" + $authString = "{0}:{1}" -f $PatTokenOwnerName, $PersonalAccessToken - # Encode the authentication string to UTF-8 bytes and then to Base64 - Write-Debug -Message "Encoding the authentication string to Base64" - $utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($authString) - $base64Auth = [System.Convert]::ToBase64String($utf8Bytes) + # Encode the authentication string to UTF-8 bytes and then to Base64 + Write-Debug -Message "Encoding the authentication string to Base64" + $utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($authString) + $base64Auth = [System.Convert]::ToBase64String($utf8Bytes) - # Construct the authentication header hashtable - Write-Debug -Message "Constructing the authentication header hashtable" - [System.Collections.Hashtable]$adoAuthenticationHeader = @{ - 'Content-Type' = 'application/json' - 'Authorization' = "Basic $base64Auth" + # Add Basic Authentication header + Write-Debug -Message "Constructing Basic Authentication header" + $adoAuthenticationHeader['Authorization'] = "Basic $base64Auth" + } + 'Bearer' { + # Trim the Bearer token to remove any accidental whitespace + $AccessToken = $AccessToken.Trim() + + # Add Bearer Authentication header + Write-Debug -Message "Constructing Bearer Authentication header" + $adoAuthenticationHeader['Authorization'] = "Bearer $AccessToken" + } } return $adoAuthenticationHeader diff --git a/src/reports/DefaultLicenseType.ps1 b/src/reports/DefaultLicenseType.ps1 index 1c9460c..13df539 100644 --- a/src/reports/DefaultLicenseType.ps1 +++ b/src/reports/DefaultLicenseType.ps1 @@ -34,10 +34,7 @@ param ( [System.String] $OrganizationId, [Parameter(Mandatory)] - [System.String] $PersonalAccessToken, - - [Parameter(Mandatory)] - [System.String] $PatTokenOwnerName + [System.String] $AccessToken ) # Source the external function to create authentication header @@ -47,7 +44,7 @@ param ( . ".\src\helper-functions\Export-ToExcel.ps1" # Configure headers for HTTP requests to the Azure DevOps API -[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -PatToken $PersonalAccessToken -PatTokenOwnerName $PatTokenOwnerName +[System.Collections.Hashtable] $headers = New-AdoAuthenticationHeader -AccessToken $AccessToken # Initialize report array [System.Object[]] $report = @() From a82f737be7616a88f44ddd0fb22431a0fa96215c Mon Sep 17 00:00:00 2001 From: gajendra Date: Thu, 24 Apr 2025 16:38:26 +0200 Subject: [PATCH 14/14] Modified the get-help --- src/reports/DefaultLicenseType.ps1 | 6 +++--- src/reports/TenantOrganizationConnections.ps1 | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/reports/DefaultLicenseType.ps1 b/src/reports/DefaultLicenseType.ps1 index 13df539..d81e025 100644 --- a/src/reports/DefaultLicenseType.ps1 +++ b/src/reports/DefaultLicenseType.ps1 @@ -16,14 +16,14 @@ This function uses the internal Azure DevOps billing API: https://azdevopscommerce.dev.azure.com/{orgId}/_apis/AzComm/DefaultLicenseType - .PARAMETER PersonalAccessToken - The Personal Access Token (PAT) for authenticating with the Azure DevOps API. + .PARAMETER AccessToken + The Access Token (bearer) for authenticating with the Azure DevOps API. .PARAMETER PatTokenOwnerName The username associated with the PAT token, typically an email. .EXAMPLE - .\DefaultLicenseType.ps1 -Organization "myOrg" -OrganizationId "20b34e00-7898-7763-950d-098764ad3d2c" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" + .\DefaultLicenseType.ps1 -Organization "myOrg" -OrganizationId "20b34e00-7898-7763-950d-098764ad3d2c" -AccessToken "bearer" -PatTokenOwnerName "Ben John" #> param ( diff --git a/src/reports/TenantOrganizationConnections.ps1 b/src/reports/TenantOrganizationConnections.ps1 index b7baa1b..d1f3577 100644 --- a/src/reports/TenantOrganizationConnections.ps1 +++ b/src/reports/TenantOrganizationConnections.ps1 @@ -12,14 +12,14 @@ The Entra ID (Azure AD) tenant ID used to scope the query. API: https://aexprodweu1.vsaex.visualstudio.com/_apis/EnterpriseCatalog/Organizations?tenantId={tenantId} - .PARAMETER PersonalAccessToken + .PARAMETER AccessToken A valid Azure DevOps Bearer token .PARAMETER PatTokenOwnerName The username associated with the PAT token, typically an email. .EXAMPLE - .\TenantOrganizationConnections.ps1 -TenantId "a74be31f-7904-4c43-8ef5-c82967c8e559" -PersonalAccessToken "myPAT" -PatTokenOwnerName "Ben John" + .\TenantOrganizationConnections.ps1 -TenantId "a74be31f-7904-4c43-8ef5-c82967c8e559" -AccessToken "bearer" -PatTokenOwnerName "Ben John" #> param (