Framework/Core/SVT/ADO/ADO.Project.ps1
Set-StrictMode -Version Latest class Project: ADOSVTBase { [PSObject] $PipelineSettingsObj = $null hidden $PAMembers = @() hidden $Repos = $null hidden $GuestMembers = @() hidden $AllUsersInOrg = @() Project([string] $organizationName, [SVTResource] $svtResource): Base($organizationName,$svtResource) { $this.Repos = $null $this.GetPipelineSettingsObj() # If switch ALtControlEvaluationMethod is set as true in org policy, then evaluating control using graph API. If not then fall back to RegEx based evaluation. if ([string]::IsNullOrWhiteSpace([IdentityHelpers]::ALTControlEvaluationMethod)) { [IdentityHelpers]::ALTControlEvaluationMethod = "GraphThenRegEx" if ([Helpers]::CheckMember($this.ControlSettings, "ALTControlEvaluationMethod")) { if (($this.ControlSettings.ALtControlEvaluationMethod -eq "Graph")) { [IdentityHelpers]::ALTControlEvaluationMethod = "Graph" } elseif (($this.ControlSettings.ALtControlEvaluationMethod -eq "RegEx")) { [IdentityHelpers]::ALTControlEvaluationMethod = "RegEx" } } } } GetPipelineSettingsObj() { $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.OrganizationContext.OrganizationName); #TODO: testing adding below line commenting above line #$apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $orgUrl = "https://dev.azure.com/{0}" -f $($this.OrganizationContext.OrganizationName); $projectName = $this.ResourceContext.ResourceName; #$inputbody = "{'contributionIds':['ms.vss-org-web.collection-admin-policy-data-provider'],'context':{'properties':{'sourcePage':{'url':'$orgUrl/_settings/policy','routeId':'ms.vss-admin-web.collection-admin-hub-route','routeValues':{'adminPivot':'policy','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json $inputbody = "{'contributionIds':['ms.vss-build-web.pipelines-general-settings-data-provider'],'dataProviderContext':{'properties':{'sourcePage':{'url':'$orgUrl/$projectName/_settings/settings','routeId':'ms.vss-admin-web.project-admin-hub-route','routeValues':{'project':'$projectName','adminPivot':'settings','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json $responseObj = $null try{ $responseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); } catch{ #Write-Host "Pipeline settings for the project [$projectName] can not be fetched." } if($responseObj){ if([Helpers]::CheckMember($responseObj,"dataProviders")) { try { if($responseObj.dataProviders.'ms.vss-build-web.pipelines-general-settings-data-provider'){ $this.PipelineSettingsObj = $responseObj.dataProviders.'ms.vss-build-web.pipelines-general-settings-data-provider' } } catch { #Write-Host "Pipeline settings for the project [$projectName] can not be fetched." } } } } hidden [ControlResult] CheckProjectVisibility([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed if([Helpers]::CheckMember($this.ResourceContext.ResourceDetails,"visibility")) { $visibility = $this.ResourceContext.ResourceDetails.visibility; if(($visibility -eq "private") -or ($visibility -eq "organization")) { $controlResult.AddMessage([VerificationResult]::Passed, "Project visibility is set to '$visibility'."); } else # For orgs with public projects allowed, this control needs to be attested by the project admins. { $controlResult.AddMessage("Project visibility is set to '$visibility'."); } $controlResult.AdditionalInfo += "Project visibility is set to: " + $visibility; } else { $controlResult.AddMessage([VerificationResult]::Error,"Project visibility details could not be fetched."); } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Project visibility details could not be fetched."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBadgeAnonAccess([ControlResult] $controlResult) { if($this.PipelineSettingsObj) { if($this.PipelineSettingsObj.statusBadgesArePrivate.enabled -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Anonymous access to status badge API is disabled. It is set as '$($this.PipelineSettingsObj.statusBadgesArePrivate.orgEnabled)' at organization scope."); } else{ $controlResult.AddMessage([VerificationResult]::Failed, "Anonymous access to status badge API is enabled. It is set as '$($this.PipelineSettingsObj.statusBadgesArePrivate.orgEnabled)' at organization scope."); } } else{ $controlResult.AddMessage([VerificationResult]::Manual, "Pipeline settings could not be fetched due to insufficient permissions at project scope."); } return $controlResult } hidden [ControlResult] CheckSettableQueueTime([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.PipelineSettingsObj) { if($this.PipelineSettingsObj.enforceSettableVar.enabled -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Only explicitly marked 'settable at queue time' variables can be set at queue time. It is set as '$($this.PipelineSettingsObj.enforceSettableVar.orgEnabled)' at organization scope."); } else{ $controlResult.AddMessage("All variables can be set at queue time. It is set as '$($this.PipelineSettingsObj.enforceSettableVar.orgEnabled)' at organization scope."); } } else{ $controlResult.AddMessage([VerificationResult]::Error, "Pipeline settings could not be fetched for the project."); } return $controlResult } hidden [ControlResult] CheckJobAuthZScope([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.enforceJobAuthScope.orgEnabled; $prjLevelScope = $this.PipelineSettingsObj.enforceJobAuthScope.enabled; if($prjLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Job authorization scope is limited to current project for non-release pipelines."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Job authorization scope is set to project collection for non-release pipelines."); } if($orgLevelScope -eq $true ) { $controlResult.AddMessage("This setting is enabled (limited to current project) at organization level."); } else { $controlResult.AddMessage("This setting is disabled (set to project collection) at organization level."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch project pipeline settings."); } return $controlResult } hidden [ControlResult] CheckJobAuthZReleaseScope([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.enforceJobAuthScopeForReleases.orgEnabled; $prjLevelScope = $this.PipelineSettingsObj.enforceJobAuthScopeForReleases.enabled; if($prjLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Job authorization scope is limited to current project for release pipelines."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Job authorization scope is set to project collection for release pipelines."); } if($orgLevelScope -eq $true ) { $controlResult.AddMessage("This setting is enabled (limited to current project) at organization level."); } else { $controlResult.AddMessage("This setting is disabled (set to project collection) at organization level."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch project pipeline settings."); } return $controlResult } hidden [ControlResult] CheckAuthZRepoScope([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.enforceReferencedRepoScopedToken.orgEnabled; $prjLevelScope = $this.PipelineSettingsObj.enforceReferencedRepoScopedToken.enabled; if($prjLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Job authorization scope of pipelines is limited to explicitly referenced Azure DevOps repositories."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Job authorization scope of pipelines is set to all Azure DevOps repositories in the authorized projects."); } if($orgLevelScope -eq $true ) { $controlResult.AddMessage("This setting is enabled (limited to explicitly referenced Azure DevOps repositories) at organization level."); } else { $controlResult.AddMessage("This setting is disabled (set to all Azure DevOps repositories in authorized projects) at organization level."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch project pipeline settings."); } return $controlResult } hidden [ControlResult] CheckPublishMetadata([ControlResult] $controlResult) { if($this.PipelineSettingsObj) { if($this.PipelineSettingsObj.publishPipelineMetadata.enabled -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Publishing metadata from pipeline is enabled. It is set as '$($this.PipelineSettingsObj.publishPipelineMetadata.orgEnabled)' at organization scope."); } else{ $controlResult.AddMessage([VerificationResult]::Failed, "Publishing metadata from pipeline is disabled. It is set as '$($this.PipelineSettingsObj.publishPipelineMetadata.orgEnabled)' at organization scope."); } } else{ $controlResult.AddMessage([VerificationResult]::Manual, "Pipeline settings could not be fetched due to insufficient permissions at project scope."); } return $controlResult } hidden [ControlResult] CheckRBACAccess([ControlResult] $controlResult) { <# This control has been currently removed from control JSON file. { "ControlID": "ADO_Project_AuthZ_Min_RBAC_Access", "Description": "All teams/groups must be granted minimum required permissions on the project.", "Id": "Project120", "ControlSeverity": "High", "Automated": "No", "MethodName": "CheckRBACAccess", "Rationale": "Granting minimum access by leveraging RBAC feature ensures that users are granted just enough permissions to perform their tasks. This minimizes exposure of the resources in case of user/service account compromise.", "Recommendation": "Refer: https://docs.microsoft.com/en-us/azure/devops/organizations/security/set-project-collection-level-permissions?view=vsts&tabs=new-nav", "Tags": [ "SDL", "TCP", "Manual", "AuthZ", "RBAC" ], "Enabled": true } #> $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $inputbody = '{"contributionIds":["ms.vss-admin-web.org-admin-groups-data-provider"],"dataProviderContext":{"properties":{"sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"permissions","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/permissions"; $inputbody.dataProviderContext.properties.sourcePage.routeValues.Project =$this.ResourceContext.ResourceName; $groupsObj = [WebRequestHelper]::InvokePostWebRequest($url,$inputbody); $Allgroups = @() $groupsObj.dataProviders."ms.vss-admin-web.org-admin-groups-data-provider".identities | ForEach-Object { $Allgroups += $_; } $descrurl ='https://vssps.dev.azure.com/{0}/_apis/graph/descriptors/{1}?api-version=6.0-preview.1' -f $($this.OrganizationContext.OrganizationName), $this.ResourceContext.ResourceId.split('/')[-1]; $descr = [WebRequestHelper]::InvokeGetWebRequest($descrurl); $apiURL = "https://vssps.dev.azure.com/{0}/_apis/Graph/Users?scopeDescriptor={1}&api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $descr[0]; $usersObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); <# $Users = @() $usersObj[0].items | ForEach-Object { $Users+= $_ } #> $groups = ($Allgroups | Select-Object -Property @{Name="Name"; Expression = {$_.displayName}},@{Name="Description"; Expression = {$_.description}}); $UsersNames = ($usersObj | Select-Object -Property @{Name="Name"; Expression = {$_.displayName}},@{Name="mailAddress"; Expression = {$_.mailAddress}}) if ( (($groups | Measure-Object).Count -gt 0) -or (($UsersNames | Measure-Object).Count -gt 0)) { $controlResult.AddMessage([VerificationResult]::Verify, "Verify users and groups present on project"); $controlResult.AddMessage("Verify groups has access on project", $groups); $controlResult.AddMessage("Verify users has access on project", $UsersNames); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users or groups found"); } return $controlResult } hidden [ControlResult] JustifyGroupMember([ControlResult] $controlResult) { $grpmember = @(); $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $inputbody = '{"contributionIds":["ms.vss-admin-web.org-admin-groups-data-provider"],"dataProviderContext":{"properties":{"sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"permissions","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/permissions"; $inputbody.dataProviderContext.properties.sourcePage.routeValues.Project =$this.ResourceContext.ResourceName; $groupsObj = [WebRequestHelper]::InvokePostWebRequest($url,$inputbody); $groups = @() $groupsObj.dataProviders."ms.vss-admin-web.org-admin-groups-data-provider".identities | ForEach-Object { $groups += $_; } $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview" -f $($this.OrganizationContext.OrganizationName); $membercount =0; Foreach ($group in $groups){ $groupmember = @(); $descriptor = $group.descriptor; $inputbody = '{"contributionIds":["ms.vss-admin-web.org-admin-members-data-provider"],"dataProviderContext":{"properties":{"subjectDescriptor":"","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"permissions","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.subjectDescriptor = $descriptor; $inputbody.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/permissions?subjectDescriptor=$($descriptor)"; $inputbody.dataProviderContext.properties.sourcePage.routeValues.Project =$this.ResourceContext.ResourceName; $usersObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); if([Helpers]::CheckMember($usersObj.dataProviders.'ms.vss-admin-web.org-admin-members-data-provider', "identities")) { $usersObj.dataProviders."ms.vss-admin-web.org-admin-members-data-provider".identities | ForEach-Object { $groupmember += $_; } } $grpmember = ($groupmember | Select-Object -Property @{Name="Name"; Expression = {$_.displayName}},@{Name="mailAddress"; Expression = {$_.mailAddress}}); if ($grpmember -ne $null) { $membercount= $membercount + 1 $controlResult.AddMessage("Verify below members of the group: '$($group.principalname)', Description: $($group.description)", $grpmember); } } if ( $membercount -gt 0) { $controlResult.AddMessage([VerificationResult]::Verify, "Verify members of groups present on project"); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users or groups found"); } return $controlResult } hidden [ControlResult] CheckMinPACount([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed $TotalPAMembers = 0; if ($this.PAMembers.Count -eq 0) { $this.PAMembers += @([AdministratorHelper]::GetTotalPAMembers($this.OrganizationContext.OrganizationName,$this.ResourceContext.ResourceName)) } if([Helpers]::CheckMember($this.PAMembers[0],"mailAddress")) { $TotalPAMembers = $this.PAMembers.Count } $controlResult.AddMessage("There are a total of $TotalPAMembers Project Administrators in your project.") if ($TotalPAMembers -gt 0) { if ([IdentityHelpers]::hasGraphAccess) { $SvcAndHumanAccounts = [IdentityHelpers]::DistinguishHumanAndServiceAccount($this.PAMembers, $this.OrganizationContext.OrganizationName) $humanAccounts = @($SvcAndHumanAccounts.humanAccount | Select-Object displayName, mailAddress) $svcAccounts = @($SvcAndHumanAccounts.serviceAccount | Select-Object displayName, mailAddress) # In case of graph access we will only evaluate the control on the basis of human accounts if($humanAccounts.count -lt $this.ControlSettings.Project.MinPAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of human administrators configured are less than the minimum required administrators count: $($this.ControlSettings.Project.MinPAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of human administrators configured meet the minimum required administrators count: $($this.ControlSettings.Project.MinPAMembersPermissible)"); } if($TotalPAMembers -gt 0){ $controlResult.AddMessage("Current set of Project Administrators: ") $controlResult.AdditionalInfo += "Count of Project Administrators: " + $TotalPAMembers; } if ($humanAccounts.count -gt 0) { $controlResult.AddMessage("`nCount of Human administrators: $($humanAccounts.Count)") $display = ($humanAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of human Project Administrators: ",$humanAccounts) } if ($svcAccounts.count -gt 0) { $controlResult.AddMessage("`nCount of Service accounts: $($svcAccounts.Count)") $display = ($svcAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of service account Project Administrators: ",$svcAccounts) } } else { $controlResult.AddMessage([Constants]::graphWarningMessage+"`n"); $this.PAMembers = @($this.PAMembers | Select-Object displayName,mailAddress) if($TotalPAMembers -lt $this.ControlSettings.Project.MinPAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of administrators configured are less than the minimum required administrators count: $($this.ControlSettings.Project.MinPAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of administrators configured meet the minimum required administrators count: $($this.ControlSettings.Project.MinPAMembersPermissible)"); } if($TotalPAMembers -gt 0){ $controlResult.AddMessage("Current set of Project Administrators: ") $display = ($this.PAMembers|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of Project Administrators: ",$this.PAMembers) $controlResult.AdditionalInfo += "Count of Project Administrators: " + $TotalPAMembers; } } } else { $controlResult.AddMessage([VerificationResult]::Failed,"No Project Administrators are configured in the project."); } return $controlResult } hidden [ControlResult] CheckMaxPACount([ControlResult] $controlResult) { $controlResult.VerificationResult = [verificationResult]::Failed; $TotalPAMembers = 0; if ($this.PAMembers.Count -eq 0) { $this.PAMembers += @([AdministratorHelper]::GetTotalPAMembers($this.OrganizationContext.OrganizationName,$this.ResourceContext.ResourceName)) } if((-not [string]::IsNullOrEmpty($this.PAMembers)) -and [Helpers]::CheckMember($this.PAMembers[0],"mailAddress")) { $TotalPAMembers = $this.PAMembers.Count $controlResult.AddMessage("There are a total of $TotalPAMembers Project Administrators in your project.") } if ($TotalPAMembers -gt 0) { if ([IdentityHelpers]::hasGraphAccess) { $SvcAndHumanAccounts = [IdentityHelpers]::DistinguishHumanAndServiceAccount($this.PAMembers, $this.OrganizationContext.OrganizationName) $humanAccounts = @($SvcAndHumanAccounts.humanAccount | Select-Object displayName, mailAddress) $svcAccounts = @($SvcAndHumanAccounts.serviceAccount | Select-Object displayName, mailAddress) $humanAccountsCount = $humanAccounts.Count $svcAccountsCount = $svcAccounts.Count #In case of graph access we will only evaluate the control on the basis of human accounts if($humanAccountsCount -gt $this.ControlSettings.Project.MaxPAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of human administrators configured are more than the approved limit: $($this.ControlSettings.Project.MaxPAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of human administrators configured are within than the approved limit: $($this.ControlSettings.Project.MaxPAMembersPermissible)"); } if($TotalPAMembers -gt 0){ $controlResult.AddMessage("Current set of Project Administrators: ") $controlResult.AdditionalInfo += "Count of Project Administrators: " + $TotalPAMembers; } if ($humanAccountsCount -gt 0) { $controlResult.AddMessage("`nCount of Human Administrators: $($humanAccountsCount)") $display = ($humanAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of human Project Administrators: ",$humanAccounts) } if ($svcAccountsCount -gt 0) { $controlResult.AddMessage("`nCount of Service Accounts: $($svcAccountsCount)") $display = ($svcAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of service account Project Administrators: ",$svcAccounts) } } else { $controlResult.AddMessage([Constants]::graphWarningMessage+"`n"); $this.PAMembers = @($this.PAMembers | Select-Object displayName,mailAddress) if($TotalPAMembers -gt $this.ControlSettings.Project.MaxPAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of administrators configured are more than the approved limit: $($this.ControlSettings.Project.MaxPAMembersPermissible)."); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of administrators configured are within than the approved limit: $($this.ControlSettings.Project.MaxPAMembersPermissible)."); } if($TotalPAMembers -gt 0){ $controlResult.AddMessage("Count of Project Administrators: $($TotalPAMembers)") $controlResult.AddMessage("Current set of Project Administrators: ") $display = ($this.PAMembers|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of Project Administrators: ",$this.PAMembers) $controlResult.AdditionalInfo += "Count of Project Administrators: " + $TotalPAMembers; } } } else { $controlResult.AddMessage([VerificationResult]::Verify,"No Project Administrators are configured in the project."); } return $controlResult } hidden [ControlResult] CheckSCALTForAdminMembers([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { if(($null -ne $this.ControlSettings) -and [Helpers]::CheckMember($this.ControlSettings, "Project.GroupsToCheckForSCAltMembers")) { $adminGroupNames = @($this.ControlSettings.Project.GroupsToCheckForSCAltMembers); if ($adminGroupNames.Count -gt 0) { #api call to get descriptor for organization groups. This will be used to fetch membership of individual groups later. $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $inputbody = '{"contributionIds":["ms.vss-admin-web.org-admin-groups-data-provider"],"dataProviderContext":{"properties":{"sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"permissions","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/permissions"; $inputbody.dataProviderContext.properties.sourcePage.routeValues.Project = $this.ResourceContext.ResourceName; $response = [WebRequestHelper]::InvokePostWebRequest($url, $inputbody); if ($response -and [Helpers]::CheckMember($response[0], "dataProviders") -and $response[0].dataProviders."ms.vss-admin-web.org-admin-groups-data-provider") { $adminGroups = @(); $adminGroups += $response.dataProviders."ms.vss-admin-web.org-admin-groups-data-provider".identities | where { $_.displayName -in $adminGroupNames } if($adminGroups.Count -gt 0) { #global variable to track admin members across all admin groups $allAdminMembers = @(); for ($i = 0; $i -lt $adminGroups.Count; $i++) { $groupMembers = @(); if ([ControlHelper]::groupMembersResolutionObj.ContainsKey($adminGroups[$i].descriptor) -and [ControlHelper]::groupMembersResolutionObj[$adminGroups[$i].descriptor].count -gt 0) { $groupMembers += [ControlHelper]::groupMembersResolutionObj[$adminGroups[$i].descriptor] } else { [ControlHelper]::FindGroupMembers($adminGroups[$i].descriptor, $this.OrganizationContext.OrganizationName,$this.ResourceContext.ResourceName) $groupMembers += [ControlHelper]::groupMembersResolutionObj[$adminGroups[$i].descriptor] } # Create a custom object to append members of current group with the group name. Each of these custom object is added to the global variable $allAdminMembers for further analysis of SC-Alt detection. $groupMembers | ForEach-Object {$allAdminMembers += @( [PSCustomObject] @{ name = $_.displayName; mailAddress = $_.mailAddress; id = $_.originId; groupName = $adminGroups[$i].displayName } )} } # Filtering out distinct entries. A user might be added directly to the admin group or might be a member of a child group of the admin group. $allAdminMembers = @($allAdminMembers| Sort-Object -Property id -Unique) if($allAdminMembers.Count -gt 0) { $useGraphEvaluation = $false $useRegExEvaluation = $false if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "GraphThenRegEx") { if ([IdentityHelpers]::hasGraphAccess){ $useGraphEvaluation = $true } else { $useRegExEvaluation = $true } } if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "Graph" -or $useGraphEvaluation) { if ([IdentityHelpers]::hasGraphAccess) { $allAdmins = [IdentityHelpers]::DistinguishAltAndNonAltAccount($allAdminMembers) $SCMembers = $allAdmins.altAccount $nonSCMembers = $allAdmins.nonAltAccount $nonSCCount = $nonSCMembers.Count $SCCount = $SCMembers.Count if ($nonSCCount -gt 0) { $nonSCMembers = $nonSCMembers | Select-Object name,mailAddress,groupName $stateData = @(); $stateData += $nonSCMembers $controlResult.AddMessage([VerificationResult]::Failed, "`nCount of non-ALT accounts with admin privileges: $nonSCCount"); $controlResult.AddMessage("List of non-ALT accounts: ", $($stateData | Format-Table -AutoSize | Out-String)); $controlResult.SetStateData("List of non-ALT accounts: ", $stateData); $controlResult.AdditionalInfo += "Count of non-ALT accounts with admin privileges: " + $nonSCCount; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users have admin privileges with non SC-ALT accounts."); } if ($SCCount -gt 0) { $SCMembers = $SCMembers | Select-Object name,mailAddress,groupName $SCData = @(); $SCData += $SCMembers $controlResult.AddMessage("`nCount of ALT accounts with admin privileges: $SCCount"); $controlResult.AdditionalInfo += "Count of ALT accounts with admin privileges: " + $SCCount; $controlResult.AddMessage("List of ALT accounts: ", $($SCData | Format-Table -AutoSize | Out-String)); } } else { $controlResult.AddMessage([VerificationResult]::Error, "The signed-in user identity does not have graph permission."); } } if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "RegEx" -or $useRegExEvaluation) { $controlResult.AddMessage([Constants]::graphWarningMessage); if([Helpers]::CheckMember($this.ControlSettings, "AlernateAccountRegularExpressionForOrg")){ $matchToSCAlt = $this.ControlSettings.AlernateAccountRegularExpressionForOrg #currently SC-ALT regex is a singleton expression. In case we have multiple regex - we need to make the controlsetting entry as an array and accordingly loop the regex here. if (-not [string]::IsNullOrEmpty($matchToSCAlt)) { $nonSCMembers = @(); $nonSCMembers += $allAdminMembers | Where-Object { $_.mailAddress -notmatch $matchToSCAlt } $nonSCCount = $nonSCMembers.Count $SCMembers = @(); $SCMembers += $allAdminMembers | Where-Object { $_.mailAddress -match $matchToSCAlt } $SCCount = $SCMembers.Count if ($nonSCCount -gt 0) { $nonSCMembers = $nonSCMembers | Select-Object name,mailAddress,groupName $stateData = @(); $stateData += $nonSCMembers $controlResult.AddMessage([VerificationResult]::Failed, "`nCount of non-ALT accounts with admin privileges: $nonSCCount"); $controlResult.AddMessage("List of non-ALT accounts: ", $($stateData | Format-Table -AutoSize | Out-String)); $controlResult.SetStateData("List of non-ALT accounts: ", $stateData); $controlResult.AdditionalInfo += "Count of non-ALT accounts with admin privileges: " + $nonSCCount; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users have admin privileges with non SC-ALT accounts."); } if ($SCCount -gt 0) { $SCMembers = $SCMembers | Select-Object name,mailAddress,groupName $SCData = @(); $SCData += $SCMembers $controlResult.AddMessage("`nCount of ALT accounts with admin privileges: $SCCount"); $controlResult.AdditionalInfo += "Count of ALT accounts with admin privileges: " + $SCCount; $controlResult.AddMessage("List of ALT accounts: ", $($SCData | Format-Table -AutoSize | Out-String)); } } else { $controlResult.AddMessage([VerificationResult]::Manual, "Regular expressions for detecting SC-ALT account is not defined in the organization."); } } else{ $controlResult.AddMessage([VerificationResult]::Error, "Regular expressions for detecting SC-ALT account is not defined in the organization. Please update your ControlSettings.json as per the latest AzSK.ADO PowerShell module."); } } } else { #count is 0 then there is no members added in the admin groups $controlResult.AddMessage([VerificationResult]::Passed, "Admin groups does not have any members."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not find the list of administrator groups in the project."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not find the list of groups in the project."); } } else { $controlResult.AddMessage([VerificationResult]::Manual, "List of administrator groups for detecting non SC-ALT accounts is not defined in your project."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "List of administrator groups for detecting non SC-ALT accounts is not defined in your project. Please update your ControlSettings.json as per the latest AzSK.ADO PowerShell module."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of groups in the project."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckAllPipelinesAccessOnFeeds([ControlResult] $controlResult) { <# { "ControlID": "ADO_Project_AuthZ_Restrict_Feed_Permissions", "Description": "Do not allow a broad group of users to upload packages to feed.", "Id": "Project230", "ControlSeverity": "High", "Automated": "Yes", "MethodName": "CheckAllPipelinesAccessOnFeeds", "Rationale": "If a broad group of users (e.g., Contributors) have permissions to upload package to feed, then integrity of your pipeline can be compromised by a malicious user who uploads a package.", "Recommendation": "1. Go to Project --> 2. Artifacts --> 3. Select Feed --> 4. Feed Settings --> 5. Permissions --> 6. Groups --> 7. Review users/groups which have administrator and contributor roles.", "Tags": [ "SDL", "TCP", "AuthZ", "RBAC", "MSW" ], "Enabled": true } #> try { $controlResult.VerificationResult = [VerificationResult]::Failed $url = 'https://feeds.dev.azure.com/{0}/{1}/_apis/packaging/feeds?api-version=6.0-preview.1' -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceName; $feedsObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); $FeedsWithBroadAccess = @(); $GroupsToCheckForFeedPermission = $null; $TotalFeedsCount = $feedsObj.Count if ( $TotalFeedsCount -gt 0 -and [Helpers]::CheckMember($feedsObj[0],"Id")) { $controlResult.AddMessage("Total number of feeds found: $($TotalFeedsCount)") $controlResult.AdditionalInfo += "Total number of feeds found: " + $TotalFeedsCount; if ($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings, "Project.GroupsToCheckForFeedPermission") ) { $GroupsToCheckForFeedPermission = @($this.ControlSettings.Project.GroupsToCheckForFeedPermission) } if($null -ne $GroupsToCheckForFeedPermission -and $GroupsToCheckForFeedPermission.Count -gt 0) { foreach ($feed in $feedsObj) { #GET https://feeds.dev.azure.com/{organization}/{project}/_apis/packaging/Feeds/{feedId}/permissions?api-version=6.0-preview.1 #Using visualstudio api because new api (dev.azure.com) is giving null in the displayName property. $url = 'https://{0}.feeds.visualstudio.com/{1}/_apis/Packaging/Feeds/{2}/Permissions?includeIds=true&excludeInheritedPermissions=false&includeDeletedFeeds=false' -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceName, $feed.Id; $feedPermissionObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); $feedsPermission = ($feedPermissionObj | Where-Object {$_.role -eq "administrator" -or $_.role -eq "contributor" -or $_.role -eq "collaborator"}) | Select-Object -Property @{Name="FeedName"; Expression = {$feed.name}},@{Name="Role"; Expression = {$_.role}},@{Name="DisplayName"; Expression = {$_.displayName}} ; $FeedsWithBroadAccess += $feedsPermission | Where-Object { $GroupsToCheckForFeedPermission -contains $_.DisplayName.split('\')[-1] } } $FeedsAtRisk = $FeedsWithBroadAccess.count; if ($FeedsAtRisk -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "List of feeds: "); $controlResult.AddMessage("`nNote: `nThe following groups are considered as broad groups:"); $controlResult.AddMessage(($GroupsToCheckForFeedPermission | FT | Out-String)) $controlResult.AddMessage("`nCount of feeds with contributor/administrator/collaborator permission: $FeedsAtRisk"); $controlResult.AdditionalInfo += "Count of feeds with contributor/administrator/collaborator permission: " + $FeedsAtRisk; $display = ($FeedsWithBroadAccess | FT FeedName, Role, DisplayName -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) } else { $controlResult.AddMessage([VerificationResult]::Passed, "No feeds in the project are exposed to uploads from broad group of users."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "List of groups for checking feed permission is not defined in control settings for your organization."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No feeds found in the project."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch project feed settings."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckEnviornmentAccess([ControlResult] $controlResult) { <# { "ControlID": "ADO_Project_AuthZ_Dont_Grant_All_Pipelines_Access_To_Environment", "Description": "Do not make environment accessible to all pipelines.", "Id": "Project240", "ControlSeverity": "High", "Automated": "Yes", "MethodName": "CheckEnviornmentAccess", "Rationale": "To support security of the pipeline operations, environments must not be granted access to all pipelines. This is in keeping with the principle of least privilege because a vulnerability in components used by one pipeline can be leveraged by an attacker to attack other pipelines having access to critical resources.", "Recommendation": "To remediate this, go to Project -> Pipelines -> Environments -> select your environment from the list -> click Security -> Under 'Pipeline Permissions', remove pipelines that environment no more requires access to or click 'Restrict Permission' to avoid granting access to all pipelines.", "Tags": [ "SDL", "TCP", "Automated", "AuthZ" ], "Enabled": true },#> $controlResult.VerificationResult = [VerificationResult]::Failed; try { $apiURL = "https://dev.azure.com/{0}/{1}/_apis/distributedtask/environments?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $($this.ResourceContext.ResourceName); $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); # TODO: When there are no environments configured, CheckMember in the below condition returns false when checknull flag [third param in CheckMember] is not specified (default value is $true). Assiging it $false. Need to revisit. if(([Helpers]::CheckMember($responseObj[0],"count",$false)) -and ($responseObj[0].count -eq 0)) { $controlResult.AddMessage([VerificationResult]::Passed, "No environment has been configured in the project."); } # When environments are configured - the below condition will be true. elseif((-not ([Helpers]::CheckMember($responseObj[0],"count"))) -and ($responseObj.Count -gt 0)) { $environmentsWithOpenAccess = @(); foreach ($item in $responseObj) { $url = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/environment/{2}" -f $($this.OrganizationContext.OrganizationName), $($this.ResourceContext.ResourceDetails.id), $($item.id); $apiResponse = @([WebRequestHelper]::InvokeGetWebRequest($url)); if (([Helpers]::CheckMember($apiResponse,"allPipelines")) -and ($apiResponse.allPipelines.authorized -eq $true)) { $environmentsWithOpenAccess += $item | Select-Object id, name; } } $environmentsWithOpenAccessCount = $environmentsWithOpenAccess.Count; if($environmentsWithOpenAccessCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Total number of environments in the project that are accessible to all pipelines: $($environmentsWithOpenAccessCount)"); $controlResult.AddMessage("List of environments in the project that are accessible to all pipelines: ", $environmentsWithOpenAccess); $controlResult.AdditionalInfo += "Total number of environments in the project that are accessible to all pipelines: " + $environmentsWithOpenAccessCount; } else { $controlResult.AddMessage([VerificationResult]::Passed, "There are no environments that are accessible to all pipelines."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No environments found in the project."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of environments in the project."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckSecureFilesPermission([ControlResult] $controlResult) { # getting the project ID <# { "ControlID": "ADO_Project_AuthZ_Dont_Grant_All_Pipelines_Access_To_Secure_Files", "Description": "Do not make secure files accessible to all pipelines.", "Id": "Project250", "ControlSeverity": "High", "Automated": "Yes", "MethodName": "CheckSecureFilesPermission", "Rationale": "If a secure file is granted access to all pipelines, an unauthorized user can steal information from the secure files by building a pipeline and accessing the secure file.", "Recommendation": "1. Go to Project --> 2. Pipelines --> 3. Library --> 4. Secure Files --> 5. select your secure file from the list --> 6. click Security --> 7. Under 'Pipeline Permissions', remove pipelines that secure file no more requires access to or click 'Restrict Permission' to avoid granting access to all pipelines.", "Tags": [ "SDL", "AuthZ", "Automated", "Best Practice", "MSW" ], "Enabled": true } #> $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($projectId)/_apis/distributedtask/securefiles?api-version=6.1-preview.1" try { $response = [WebRequestHelper]::InvokeGetWebRequest($url); # check on response object, if null -> no secure files present if(([Helpers]::CheckMember($response[0],"count",$false)) -and ($response[0].count -eq 0)) { $controlResult.AddMessage([VerificationResult]::Passed, "There are no secure files present."); } # else there are secure files present elseif((-not ([Helpers]::CheckMember($response[0],"count"))) -and ($response.Count -gt 0)) { # object to keep a track of authorized secure files and their count [Hashtable] $secFiles = @{ count = 0; names = @(); }; foreach ($secFile in $response) { $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($projectId)/_apis/build/authorizedresources?type=securefile&id=$($secFile.id)&api-version=6.0-preview.1" $resp = [WebRequestHelper]::InvokeGetWebRequest($url); # check if the secure file is authorized if((-not ([Helpers]::CheckMember($resp[0],"count"))) -and ($resp.Count -gt 0)) { if([Helpers]::CheckMember($resp, "authorized")) { if($resp.authorized) { $secFiles.count += 1; $secFiles.names += $secFile.name; } } } } # there are secure files present that are authorized if($secFiles.count -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Total number of secure files in the project that are authorized for use in all pipelines: $($secFiles.count)"); $controlResult.AddMessage("List of secure files in the project that are authorized for use in all pipelines: ", $secFiles.names); $controlResult.AdditionalInfo += "Total number of secure files in the project that are authorized for use in all pipelines: " + $secFiles.count; } # there are no secure files present that are authorized else { $controlResult.AddMessage([VerificationResult]::Passed, "There are no secure files in the project that are authorized for use in all pipelines."); } } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of secure files."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckAuthorEmailValidationPolicy([ControlResult] $controlResult) { # body for post request $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $inputbody = '{"contributionIds":["ms.vss-code-web.repository-policies-data-provider"],"dataProviderContext":{"properties":{"projectId": "","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"repositories","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.projectId = "$($this.ResourceContext.ResourceDetails.id)" $inputbody.dataProviderContext.properties.sourcePage.routeValues.project = "$($this.ResourceContext.ResourceName)" $inputbody.dataProviderContext.properties.sourcePage.url = "https://$($this.OrganizationContext.OrganizationName).visualstudio.com/$($this.ResourceContext.ResourceName)/_settings/repositories?_a=policies" try { $response = [WebRequestHelper]::InvokePostWebRequest($url, $inputbody); if ([Helpers]::CheckMember($response, "dataProviders") -and $response.dataProviders.'ms.vss-code-web.repository-policies-data-provider' -and [Helpers]::CheckMember($response.dataProviders.'ms.vss-code-web.repository-policies-data-provider', "policyGroups")) { # fetching policy groups $policyGroups = $response.dataProviders."ms.vss-code-web.repository-policies-data-provider".policyGroups # fetching "Commit author email validation" $authorEmailPolicyId = $this.ControlSettings.Repo.AuthorEmailValidationPolicyID $commitAuthorEmailPattern = $this.ControlSettings.Repo.CommitAuthorEmailPattern if ([Helpers]::CheckMember($policyGroups, $authorEmailPolicyId)) { $currentScopePoliciesEmail = $policyGroups."$($authorEmailPolicyId)".currentScopePolicies $controlResult.AddMessage("`nNote: Commits from the following email ids are considered as 'trusted': `n`t[$($commitAuthorEmailPattern -join ', ')]"); # validating email patterns $flag = 0; $emailPatterns = $currentScopePoliciesEmail.settings.authorEmailPatterns $invalidPattern = @() if ($emailPatterns -eq $null) { $flag = 1; } else { foreach ($val in $emailPatterns) { if ($val -notin $commitAuthorEmailPattern -and (-not [string]::IsNullOrEmpty($val))) { $flag = 1; $invalidPattern += $val } } } if ($flag -eq 0) { $controlResult.AddMessage([VerificationResult]::Passed, "Commit author email validation is set as per the organizational requirements."); } else { $controlResult.AddMessage([VerificationResult]::Verify, "Commit author email validation is not set as per the organizational requirements."); if($invalidPattern.Count -gt 0) { $controlResult.AddMessage("List of commit author email patterns that are not trusted: $($invalidPattern)") } } } else { $controlResult.AddMessage([VerificationResult]::Failed, "'Commit author email validation' policy is disabled."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch repository policies."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch repository policies $($_)."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckCredentialsAndSecretsPolicy([ControlResult] $controlResult) { # body for post request $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $inputbody = '{"contributionIds":["ms.vss-code-web.repository-policies-data-provider"],"dataProviderContext":{"properties":{"projectId": "","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"repositories","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.projectId = "$($this.ResourceContext.ResourceDetails.id)" $inputbody.dataProviderContext.properties.sourcePage.routeValues.project = "$($this.ResourceContext.ResourceName)" $inputbody.dataProviderContext.properties.sourcePage.url = "https://$($this.OrganizationContext.OrganizationName).visualstudio.com/$($this.ResourceContext.ResourceName)/_settings/repositories?_a=policies" try { $response = [WebRequestHelper]::InvokePostWebRequest($url, $inputbody); if ([Helpers]::CheckMember($response, "dataProviders") -and $response.dataProviders.'ms.vss-code-web.repository-policies-data-provider' -and [Helpers]::CheckMember($response.dataProviders.'ms.vss-code-web.repository-policies-data-provider', "policyGroups")) { # fetching policy groups $policyGroups = $response.dataProviders."ms.vss-code-web.repository-policies-data-provider".policyGroups # fetching "Secrets scanning restriction" $credScanId = $this.ControlSettings.Repo.CredScanPolicyID if ([Helpers]::CheckMember($policyGroups, $credScanId)) { $currentScopePoliciesSecrets = $policyGroups."$($credScanId)".currentScopePolicies if ($currentScopePoliciesSecrets.isEnabled) { $controlResult.AddMessage([VerificationResult]::Passed, "Check for credentials and other secrets is enabled."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Check for credentials and other secrets is disabled."); } } else { $controlResult.AddMessage([VerificationResult]::Failed, "Check for credentials and other secrets is disabled."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch repository policies."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch repository policies $($_)."); $controlResult.LogException($_) } return $controlResult } hidden [PSObject] FetchRepositoriesList() { if($null -eq $this.Repos) { # fetch repositories $repoDefnURL = ("https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_apis/git/repositories?api-version=6.1-preview.1") try { $repoDefnsObj = [WebRequestHelper]::InvokeGetWebRequest($repoDefnURL); $this.Repos = $repoDefnsObj; } catch { $this.Repos = $null } } return $this.Repos } hidden [ControlResult] CheckInactiveRepo([ControlResult] $controlResult) { <# { "ControlID": "ADO_Project_DP_Inactive_Repos", "Description": "Inactive repositories must be removed if no more required.", "Id": "Project280", "ControlSeverity": "Medium", "Automated": "Yes", "MethodName": "CheckInactiveRepo", "Rationale": "Each additional repository being accessed by pipelines increases the attack surface. To minimize this risk ensure that only active and legitimate repositories are present in project.", "Recommendation": "To remove inactive repository, follow the steps given here: 1. Navigate to the project settings -> 2. Repositories -> 3. Select the repository and delete.", "Tags": [ "SDL", "TCP", "Automated", "DP" ], "Enabled": true } #> try { $repoDefnsObj = $this.FetchRepositoriesList() $inactiveRepos = @() $threshold = $this.ControlSettings.Repo.RepoHistoryPeriodInDays if (-not ($repoDefnsObj.Length -eq 1 -and [Helpers]::CheckMember($repoDefnsObj,"count") -and $repoDefnsObj[0].count -eq 0)) { $currentDate = Get-Date foreach ($repo in $repoDefnsObj) { # check if repo is disabled or not if($repo.isDisabled) { $inactiveRepos += $repo.name } else { # check if repo has commits in past RepoHistoryPeriodInDays days $thresholdDate = $currentDate.AddDays(-$threshold); $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_apis/git/repositories/$($repo.id)/commits?searchCriteria.fromDate=$($thresholdDate)&&api-version=6.0" try{ $res = [WebRequestHelper]::InvokeGetWebRequest($url); # When there are no commits, CheckMember in the below condition returns false when checknull flag [third param in CheckMember] is not specified (default value is $true). Assiging it $false. if (([Helpers]::CheckMember($res[0], "count", $false)) -and ($res[0].count -eq 0)) { $inactiveRepos += $repo.name } } catch{ $controlResult.AddMessage("Could not fetch the history of repository [$($repo.name)]."); $controlResult.LogException($_) } } } $inactivecount = $inactiveRepos.Count if ($inactivecount -gt 0) { $inactiveRepos = $inactiveRepos | sort-object $controlResult.AddMessage([VerificationResult]::Failed, "Total number of inactive repositories that have no commits in last $($threshold) days: $($inactivecount) ", $inactiveRepos); } else { $controlResult.AddMessage([VerificationResult]::Passed, "There are no inactive repositories in the project."); } } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of repositories in the project.", $_); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckRepoRBACAccess([ControlResult] $controlResult) { <# { "ControlID": "ADO_Project_AuthZ_Repo_Grant_Min_RBAC_Access", "Description": "All teams/groups must be granted minimum required permissions on repositories.", "Id": "Project290", "ControlSeverity": "High", "Automated": "Yes", "MethodName": "CheckRepoRBACAccess", "Rationale": "Granting minimum access by leveraging RBAC feature ensures that users are granted just enough permissions to perform their tasks. This minimizes exposure of the resources in case of user/service account compromise.", "Recommendation": "Go to Project Settings --> Repositories --> Permissions --> Validate whether each user/group is granted minimum required access to repositories.", "Tags": [ "SDL", "TCP", "Automated", "AuthZ", "RBAC" ], "Enabled": true } #> $accessList = @() #permissionSetId = '2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87' is the std. namespaceID. Refer: https://docs.microsoft.com/en-us/azure/devops/organizations/security/manage-tokens-namespaces?view=azure-devops#namespaces-and-their-ids try{ $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $refererUrl = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/repositories?_a=permissions"; $inputbody = '{"contributionIds":["ms.vss-admin-web.security-view-members-data-provider"],"dataProviderContext":{"properties":{"permissionSetId": "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87","permissionSetToken":"","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"repositories","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.sourcePage.url = $refererUrl $inputbody.dataProviderContext.properties.sourcePage.routeValues.Project = $this.ResourceContext.ResourceName; $inputbody.dataProviderContext.properties.permissionSetToken = "repoV2/$($this.ResourceContext.ResourceDetails.id)" # Get list of all users and groups granted permissions on all repositories $responseObj = [WebRequestHelper]::InvokePostWebRequest($url, $inputbody); # Iterate through each user/group to fetch detailed permissions list if([Helpers]::CheckMember($responseObj[0],"dataProviders") -and ($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider') -and ([Helpers]::CheckMember($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider',"identities"))) { $body = '{"contributionIds":["ms.vss-admin-web.security-view-permissions-data-provider"],"dataProviderContext":{"properties":{"subjectDescriptor":"","permissionSetId": "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87","permissionSetToken":"","accountName":"","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"repositories","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $body.dataProviderContext.properties.sourcePage.url = $refererUrl $body.dataProviderContext.properties.sourcePage.routeValues.Project = $this.ResourceContext.ResourceName; $body.dataProviderContext.properties.permissionSetToken = "repoV2/$($this.ResourceContext.ResourceDetails.id)" $accessList += $responseObj.dataProviders."ms.vss-admin-web.security-view-members-data-provider".identities | Where-Object { $_.subjectKind -eq "group" } | ForEach-Object { $identity = $_ $body.dataProviderContext.properties.accountName = $_.principalName $body.dataProviderContext.properties.subjectDescriptor = $_.descriptor $identityPermissions = [WebRequestHelper]::InvokePostWebRequest($url, $body); $configuredPermissions = $identityPermissions.dataproviders."ms.vss-admin-web.security-view-permissions-data-provider".subjectPermissions | Where-Object {$_.permissionDisplayString -ne 'Not set'} return @{ IdentityName = $identity.DisplayName; IdentityType = $identity.subjectKind; Permissions = ($configuredPermissions | Select-Object @{Name="Name"; Expression = {$_.displayName}},@{Name="Permission"; Expression = {$_.permissionDisplayString}}) } } $accessList += $responseObj.dataProviders."ms.vss-admin-web.security-view-members-data-provider".identities | Where-Object { $_.subjectKind -eq "user" } | ForEach-Object { $identity = $_ $body.dataProviderContext.properties.subjectDescriptor = $_.descriptor $identityPermissions = [WebRequestHelper]::InvokePostWebRequest($url, $body); $configuredPermissions = $identityPermissions.dataproviders."ms.vss-admin-web.security-view-permissions-data-provider".subjectPermissions | Where-Object {$_.permissionDisplayString -ne 'Not set'} return @{ IdentityName = $identity.DisplayName; IdentityType = $identity.subjectKind; Permissions = ($configuredPermissions | Select-Object @{Name="Name"; Expression = {$_.displayName}},@{Name="Permission"; Expression = {$_.permissionDisplayString}}) } } } if(($accessList | Measure-Object).Count -ne 0) { $accessList= $accessList | Select-Object -Property @{Name="IdentityName"; Expression = {$_.IdentityName}},@{Name="IdentityType"; Expression = {$_.IdentityType}},@{Name="Permissions"; Expression = {$_.Permissions}} $controlResult.AddMessage([VerificationResult]::Verify,"Validate that the following identities have been provided with minimum RBAC access to repositories.", $accessList); $controlResult.SetStateData("List of identities having access to repositories: ", ($responseObj.dataProviders."ms.vss-admin-web.security-view-members-data-provider".identities | Select-Object -Property @{Name="IdentityName"; Expression = {$_.FriendlyDisplayName}},@{Name="IdentityType"; Expression = {$_.subjectKind}},@{Name="Scope"; Expression = {$_.Scope}})); } else { $controlResult.AddMessage([VerificationResult]::Passed,"No identities have been explicitly provided access to repositories."); } $responseObj = $null; } catch{ $controlResult.AddMessage([VerificationResult]::Manual,"Unable to fetch repositories permission details. $($_) Please verify from portal all teams/groups are granted minimum required permissions."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckInheritedPermissions([ControlResult] $controlResult) { <# { "ControlID": "ADO_Project_AuthZ_Disable_Repo_Inherited_Permissions", "Description": "Do not allow inherited permission on repositories.", "Id": "Project300", "ControlSeverity": "High", "Automated": "Yes", "MethodName": "CheckInheritedPermissions", "Rationale": "Disabling inherited permissions lets you finely control access to various operations at the repository level for different stakeholders. This ensures that you follow the principle of least privilege and provide access only to the persons that require it.", "Recommendation": "Go to Project Settings --> Repositories --> Select a repository --> Permissions --> Disable 'Inheritance'.", "Tags": [ "SDL", "TCP", "Automated", "AuthZ" ], "Enabled": true }, #> $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] #permissionSetId = '2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87' is the std. namespaceID. Refer: https://docs.microsoft.com/en-us/azure/devops/organizations/security/manage-tokens-namespaces?view=azure-devops#namespaces-and-their-ids $repoNamespaceId = '2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87' try { $repoPermissionUrl = 'https://dev.azure.com/{0}/_apis/accesscontrollists/{1}?api-version=6.0' -f $this.OrganizationContext.OrganizationName, $repoNamespaceId; $responseObj = [WebRequestHelper]::InvokeGetWebRequest($repoPermissionUrl) if ($null -ne $responseObj -and ($responseObj | Measure-Object).Count -gt 0) { $repoDefnsObj = $this.FetchRepositoriesList() $failedRepos = @() $passedRepos = @() foreach ($repo in $repoDefnsObj) { $repoToken = "repoV2/$projectId/$($repo.id)" $repoObj = $responseObj | where-object {$_.token -eq $repoToken} if ($null -ne $repoObj -and ($repoObj | Measure-Object).Count -gt 0 -and $repoObj.inheritPermissions) { $failedRepos += $repo.name } else { $passedRepos += $repo.name } } $failedReposCount = $failedRepos.Count $passedReposCount = $passedRepos.Count $passedRepos = $passedRepos | sort-object if($failedReposCount -gt 0) { $failedRepos = $failedRepos | sort-object $controlResult.AddMessage([VerificationResult]::Failed, "Inherited permissions are enabled on the repositories."); $controlResult.AddMessage("Total number of repositories on which inherited permissions are enabled: $failedReposCount", $failedRepos); $controlResult.AddMessage("Total number of repositories on which inherited permissions are disabled: $passedReposCount", $passedRepos); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Inherited permissions are disabled on all repositories."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the permission details for repositories in the project."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch list of repositories in the project. $($_)."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckInactiveProject([ControlResult] $controlResult) { # these variables are used to make table which will show inactivity status for resource types. $Reporow = @() $Buildrow = @() $Releaserow = @() $AgentPoolrow = @() $ServiceConnectionrow = @() ## Checking Inactive Repos $IsRepoActive = $false $inactiveRepocount = 0 try { $repoDefnsObj = $this.FetchRepositoriesList() if(($repoDefnsObj | Measure-Object).count -gt 0 -and -not ([Helpers]::CheckMember($repoDefnsObj,"count") -and $repoDefnsObj[0].count -eq 0) ) { # filtering out the disabled $repoDefnsObj = $repoDefnsObj | Where-Object { $_.IsDisabled -ne $true } $inactiveRepos = @() $threshold = $this.ControlSettings.Repo.RepoHistoryPeriodInDays $currentDate = Get-Date $thresholdDate = $currentDate.AddDays(-$threshold); foreach ($repo in $repoDefnsObj) { # check if repo has commits in past RepoHistoryPeriodInDays days $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_apis/git/repositories/$($repo.id)/commits?searchCriteria.fromDate=$($thresholdDate)&&api-version=6.0" try{ $res = [WebRequestHelper]::InvokeGetWebRequest($url); # When there are no commits, CheckMember in the below condition returns false when checknull flag [third param in CheckMember] is not specified (default value is $true). Assiging it $false. if (([Helpers]::CheckMember($res[0], "count", $false)) -and ($res[0].count -eq 0)) { $inactiveRepos += $repo.name } } catch{ $controlResult.AddMessage("Could not fetch the history of repository [$($repo.name)]."); $controlResult.LogException($_) } } $inactiveRepocount = $inactiveRepos.Count if ($inactiveRepocount -gt 0) { if($inactiveRepocount -ne ($repoDefnsObj | Measure-Object).count) { $IsRepoActive = $true } $inactiveRepos = $inactiveRepos | sort-object $Reporow = New-Object psobject -Property $([ordered] @{"Resource Type"="Repository";"IsActive"="$($IsRepoActive)"; "Additional Info" = "Total number of inactive repositories that have no commits in last $($threshold) days: $($inactiveRepocount) => {$($inactiveRepos -join ", ")} "}) } else { $IsRepoActive = $true $Reporow = New-Object psobject -Property $([ordered] @{"Resource Type"="Repository";"IsActive"="$($IsRepoActive)"; "Additional Info" = "There are no inactive repositories in the project."}) } } else { $Reporow = New-Object psobject -Property $([ordered] @{"Resource Type"="Repository";"IsActive"="$($IsRepoActive)"; "Additional Info" = "All repositories are disabled in the project."}) } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch the list of repositories in the project."); $controlResult.LogException($_) } ## Checking Inactive build $IsBuildActive = $false $threshold = $this.ControlSettings.Build.BuildHistoryPeriodInDays $currentDate = Get-Date $thresholdDate = $currentDate.AddDays(-$threshold); $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_apis/build/builds?minTime=$($thresholdDate)&api-version=6.0" try{ $res = [WebRequestHelper]::InvokeGetWebRequest($url); if(-not ([Helpers]::CheckMember($res,"count") -and $res[0].count -eq 0 -and $res.Length -eq 1 )) { $res = $res | Sort-Object -Property queueTime -Descending # most recent/latest build first if($res[0].queueTime -gt $thresholdDate) { ## active build $IsBuildActive = $true $Buildrow = New-Object psobject -Property $([ordered] @{"Resource Type"="Build definition";"IsActive"="$($IsBuildActive)"; "Additional Info" = "Builds are queued in the project. Most recent build is [$($res[0].definition.name)] which was last queued on [$($res[0].queueTime)]"}) } else { $Buildrow = New-Object psobject -Property $([ordered] @{"Resource Type"="Build definition";"IsActive"="$($IsBuildActive)"; "Additional Info" = "Builds are not queued in the project."}) } } else { $Buildrow = New-Object psobject -Property $([ordered] @{"Resource Type"="Build definition";"IsActive"="$($IsBuildActive)"; "Additional Info" = "No builds are created/queued since [$($thresholdDate)]"}) } } catch{ $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch build details after timpestamp: [$($thresholdDate)]."); $controlResult.LogException($_) } ## Checking Inactive Release $IsReleaseActive = $false $threshold = $this.ControlSettings.Release.ReleaseHistoryPeriodInDays $currentDate = Get-Date $thresholdDate = $currentDate.AddDays(-$threshold); # Below API will arrange all deployments in project in descending order (latest first) and give first object of that sorted array $url = "https://vsrm.dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_apis/release/deployments?queryOrder=descending&`$top=1" try{ $res = [WebRequestHelper]::InvokeGetWebRequest($url); if(-not ([Helpers]::CheckMember($res,"count") -and $res[0].count -eq 0 -and $res.Length -eq 1)) { # $res[0] will contain latest release deployment if($res[0].queuedOn -gt $thresholdDate) { ## active Release $IsReleaseActive = $true $Releaserow = New-Object psobject -Property $([ordered] @{"Resource Type"="Release definition";"IsActive"="$($IsReleaseActive)"; "Additional Info" = "Releases are queued in the project.Most recent release is [$($res[0].release.name)] of release definition [$($res[0].releasedefinition.name)] which was last queued on [$($res[0].queuedOn)]"}) } else { $Releaserow = New-Object psobject -Property $([ordered] @{"Resource Type"="Release definition";"IsActive"="$($IsReleaseActive)"; "Additional Info" = "Releases are not queued in the project."}) } } else { $Releaserow = New-Object psobject -Property $([ordered] @{"Resource Type"="Release definition";"IsActive"="$($IsReleaseActive)"; "Additional Info" = "No Releases are created/queued since [$($thresholdDate)]"}) } } catch{ $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch Release details after timpestamp: [$($thresholdDate)]."); $controlResult.LogException($_) } ## Checking AgentPools $IsAgentPoolActive = $false $thresholdLimit = $this.ControlSettings.AgentPool.AgentPoolHistoryPeriodInDays # Fetch All Agent Pools $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_apis/distributedtask/queues?api-version=6.0-preview.1" try{ $res = [WebRequestHelper]::InvokeGetWebRequest($url); $taskAgentQueues = @() if(($res | Measure-Object).Count -ne 0) { # Filter out legacy agent pools (Hosted, Hosted VS 2017 etc.) as they are not visible to user on the portal. $taskAgentQueues = $res | where-object{ ($_.pool.isLegacy -eq $false)}; if(($taskAgentQueues | Measure-Object).Count -ne 0) { foreach ($AgentPool in $taskAgentQueues) { $url = "https://dev.azure.com/{0}/{1}/_settings/agentqueues?queueId={2}&__rt=fps&__ver=2" -f $($this.OrganizationContext.OrganizationName), $($this.ResourceContext.ResourceDetails.id) ,$AgentPool.id; $res = [WebRequestHelper]::InvokeGetWebRequest($url); if (([Helpers]::CheckMember($res[0], "fps.dataProviders.data") ) -and ($res[0].fps.dataProviders.data."ms.vss-build-web.agent-jobs-data-provider")) { #Filtering agent pool jobs specific to the current project. $agentPoolJobs = $res[0].fps.dataProviders.data."ms.vss-build-web.agent-jobs-data-provider".jobs | Where-Object {$_.scopeId -eq $this.ResourceContext.ResourceDetails.id}; #Arranging in descending order of run time. $agentPoolJobs = $agentPoolJobs | Sort-Object queueTime -Descending #If agent pool has been queued at least once if (($agentPoolJobs | Measure-Object).Count -gt 0) { #Get the last queue timestamp of the agent pool if ([Helpers]::CheckMember($agentPoolJobs[0], "finishTime")) { $agtPoolLastRunDate = $agentPoolJobs[0].finishTime; if ((((Get-Date) - $agtPoolLastRunDate).Days) -gt $thresholdLimit) { ## Inactive pool continue } else { ## Active pool $IsAgentPoolActive = $true $AgentPoolrow = New-Object psobject -Property $([ordered] @{"Resource Type"="AgentPool";"IsActive"="$($IsAgentPoolActive)"; "Additional Info" = "Agent pool has been queued in the last $thresholdLimit days."}) break } } else { ## Active pool $IsAgentPoolActive = $true $AgentPoolrow = New-Object psobject -Property $([ordered] @{"Resource Type"="AgentPool";"IsActive"="$($IsAgentPoolActive)"; "Additional Info" = "Agent pool was being queued during control evaluation."}) break } } else { continue } } else { $controlResult.AddMessage("Could not fetch agent pool details."); } } if(-not $IsAgentPoolActive) { $AgentPoolrow = New-Object psobject -Property $([ordered] @{"Resource Type"="AgentPool";"IsActive"="$($IsAgentPoolActive)"; "Additional Info" = "Agent pool has not been queued in the last $thresholdLimit days."}) } } else { $AgentPoolrow = New-Object psobject -Property $([ordered] @{"Resource Type"="AgentPool";"IsActive"="$($IsAgentPoolActive)"; "Additional Info" = "No Agent pools are there in project."}) } } else { $controlResult.AddMessage("Could not fetch Agent pool details") } } catch{ $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch Agent pool details"); $controlResult.LogException($_) } # Checking Service Connections $IsServiceConnectionActive = $false $thresholdLimit = $this.ControlSettings.ServiceConnection.ServiceConnectionHistoryPeriodInDays $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_apis/serviceendpoint/endpoints?api-version=6.0-preview.4" try { $res = [WebRequestHelper]::InvokeGetWebRequest($url); if(-not ([Helpers]::CheckMember($res,"count") -and $res[0].count -eq 0 -and $res.Length -eq 1 )) { foreach ($endpoint in $res) { $url ="https://dev.azure.com/{0}/{1}/_apis/serviceendpoint/{2}/executionhistory?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $($this.ResourceContext.ResourceDetails.id) ,$endpoint.id; $endpointUsage = [WebRequestHelper]::InvokeGetWebRequest($url); if ([Helpers]::CheckMember($endpointUsage[0],"data") -and [Helpers]::CheckMember($endpointUsage[0].data,"finishTime")) { $SClastRunDate = $endpointUsage[0].data.finishTime #format date $formatLastRunTimeSpan = New-TimeSpan -Start (Get-Date $SClastRunDate) if ($formatLastRunTimeSpan.Days -gt $thresholdLimit) { # Inactive continue } else { $IsServiceConnectionActive = $true $ServiceConnectionrow = New-Object psobject -Property $([ordered] @{"Resource Type"="ServiceConnection";"IsActive"="$($IsServiceConnectionActive)"; "Additional Info" = "Service connection has been used in the last $thresholdLimit days."}) break } } } if(-not $IsServiceConnectionActive) { $ServiceConnectionrow = New-Object psobject -Property $([ordered] @{"Resource Type"="ServiceConnection";"IsActive"="$($IsServiceConnectionActive)"; "Additional Info" = "Service connection has never been used."}) } } else { $ServiceConnectionrow = New-Object psobject -Property $([ordered] @{"Resource Type"="ServiceConnection";"IsActive"="$($IsServiceConnectionActive)"; "Additional Info" = "No Service Connections are present in project."}) } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch Service connection details"); $controlResult.LogException($_) } if( $controlResult.VerificationResult -ne [VerificationResult]::Error) { $controlResult.AddMessage("Below mentioned resource types are considered for checking inactivity of a project:") $table = @($Reporow;$Buildrow;$Releaserow;$AgentPoolrow;$ServiceConnectionrow) | Format-Table -AutoSize | Out-String -Width 512 $controlResult.AddMessage($table) $IsProjectActive = $IsRepoActive -or $IsBuildActive -or $IsReleaseActive -or $IsAgentPoolActive -or $IsServiceConnectionActive if($IsProjectActive) { if(($inactiveRepocount -gt 0) -and (($IsRepoActive -eq $true) -and ($IsBuildActive -eq $true) -and ($IsReleaseActive -eq $true) -and ($IsAgentPoolActive -eq $true) -and ($IsServiceConnectionActive -eq $true))) { $controlResult.AddMessage([VerificationResult]::Verify,"One or more repositories are inactive.") } else { $controlResult.AddMessage([VerificationResult]::Passed,"Project is active. See above table.") } } else { $controlResult.AddMessage([VerificationResult]::Failed,"Project is inactive. See above table.") } } return $controlResult } hidden [void] FetchGuestMembersInOrg() { try { $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?%24filter=userType%20eq%20%27guest%27&%24orderBy=name%20Ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName) $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $guestAccounts = @() if(($null -ne $responseObj) -and $responseObj.Count -gt 0 -and ([Helpers]::CheckMember($responseObj[0], 'members'))) { $guestAccounts = @($responseObj[0].members) $continuationToken = $responseObj[0].continuationToken # Use the continuationToken for pagination while ($null -ne $continuationToken){ $urlEncodedToken = [System.Web.HttpUtility]::UrlEncode($continuationToken) $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?continuationToken=$urlEncodedToken&%24filter=userType%20eq%20%27guest%27&%24orderBy=name%20Ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName); try{ $response = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $guestAccounts += $response[0].members $continuationToken = $response[0].continuationToken } catch { # Eating the exception here as we could not fetch the further guest users $continuationToken = $null throw } } $this.GuestMembers = @($guestAccounts) } } catch { throw } } hidden [void] FetchAllUsersInOrg() { try { $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?filter=&sortOption=lastAccessDate+ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName) $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $AllUsersAccounts = @() if(($null -ne $responseObj) -and $responseObj.Count -gt 0 -and ([Helpers]::CheckMember($responseObj[0], 'members'))) { $AllUsersAccounts = @($responseObj[0].members) $continuationToken = $responseObj[0].continuationToken # Use the continuationToken for pagination while ($null -ne $continuationToken){ $urlEncodedToken = [System.Web.HttpUtility]::UrlEncode($continuationToken) $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?continuationToken=$urlEncodedToken&filter=&sortOption=lastAccessDate+ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName); try{ $response = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $AllUsersAccounts += $response[0].members $continuationToken = $response[0].continuationToken } catch { # Eating the exception here as we could not fetch the further guest users $continuationToken = $null throw } } $this.AllUsersInOrg = @($AllUsersAccounts) } } catch { throw } } hidden [ControlResult] CheckGuestUsersAccessInAdminRoles([ControlResult] $controlResult) { if($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings,"Project.AdminGroupsToCheckForGuestUser")) { try { $controlResult.VerificationResult = [VerificationResult]::Failed $AdminGroupsToCheckForGuestUser = @($this.ControlSettings.Project.AdminGroupsToCheckForGuestUser) if($this.GuestMembers.Count -eq 0) { $this.FetchGuestMembersInOrg() } $guestAccounts = @($this.GuestMembers) if($guestAccounts.Count -gt 0) { $formattedData = @() $guestAccounts | ForEach-Object { if([Helpers]::CheckMember($_,"user.descriptor")) { try { $url = "https://vssps.dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/Graph/Memberships/$($_.user.descriptor)?api-version=6.0-preview.1" $response = @([WebRequestHelper]::InvokeGetWebRequest($url)); if([Helpers]::CheckMember($response[0],"containerDescriptor")) { foreach ($obj in $response) { $url = "https://vssps.dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/graph/groups/$($obj.containerDescriptor)?api-version=6.0-preview.1"; $res = @([WebRequestHelper]::InvokeGetWebRequest($url)); $data = $res.principalName.Split("\"); $scope = $data[0] -replace '[\[\]]' $group = $data[1] if($scope -eq $this.ResourceContext.ResourceName -and ($group -in $AdminGroupsToCheckForGuestUser) ) { $formattedData += @{ Group = $data[1]; Scope = $data[0]; Name = $_.user.displayName; PrincipalName = $_.user.principalName; } } } } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch the membership details for the user") } } else { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch descriptor for guest user"); } } if($formattedData.Count -gt 0) { $formattedData = $formattedData | select-object @{Name="Display Name"; Expression={$_.Name}}, @{Name="User or scope"; Expression={$_.Scope}} , @{Name="Group"; Expression={$_.Group}}, @{Name="Principal Name"; Expression={$_.PrincipalName}} $groups = $formattedData | Group-Object "Principal Name" $results = @() $results += foreach( $grpObj in $groups ){ $PrincipalName = $grpObj.name $OrgGroup = $grpObj.group.group -join ',' $DisplayName = $grpObj.group."Display Name" | select -Unique $Scope = $grpObj.group."User or scope" | select -Unique [PSCustomObject]@{ PrincipalName = $PrincipalName ; DisplayName = $DisplayName ; Group = $OrgGroup ; Scope = $Scope } } $controlResult.AddMessage([VerificationResult]::Failed,"Count of guest users in admin roles: $($results.count) "); $controlResult.AddMessage("`nGuest users list :") $display = ($results | FT PrincipalName, DisplayName, Group -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("Guest users list : ", $results); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No guest users have admin roles in the project."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No guest users found in organization."); } $controlResult.AddMessage("`nNote:`nThe following groups are considered for administrator privileges: `n$($AdminGroupsToCheckForGuestUser | FT | out-string)`n"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch user entitlements."); $controlResult.LogException($_) } } else{ $controlResult.AddMessage([VerificationResult]::Error, "List of admin groups for detecting guest accounts is not defined in control setting of your organization."); } return $controlResult } hidden [ControlResult] CheckInactiveUsersInAdminRoles([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings,"Project.AdminGroupsToCheckForInactiveUser")) { try { $AdminGroupsToCheckForInactiveUser = @($this.ControlSettings.Project.AdminGroupsToCheckForInactiveUser) $inactiveUsersWithAdminAccess = @() $inactivityPeriodInDays = 90 if([Helpers]::CheckMember($this.ControlSettings,"Project.AdminInactivityThresholdInDays")) { $inactivityPeriodInDays = $this.ControlSettings.Organization.AdminInactivityThresholdInDays } $thresholdDate = (Get-Date).AddDays(-$inactivityPeriodInDays) ## API Call to fetch project level groups $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $inputbody = '{"contributionIds":["ms.vss-admin-web.org-admin-groups-data-provider"],"dataProviderContext":{"properties":{"sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"project":"","adminPivot":"permissions","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/permissions"; $inputbody.dataProviderContext.properties.sourcePage.routeValues.Project = $this.ResourceContext.ResourceName; $response = [WebRequestHelper]::InvokePostWebRequest($url, $inputbody); if([Helpers]::CheckMember($response[0],"dataProviders") -and $response[0].dataProviders."ms.vss-admin-web.org-admin-groups-data-provider") { $ReqdAdminGroups = @(); $ReqdAdminGroups += $response.dataProviders."ms.vss-admin-web.org-admin-groups-data-provider".identities | where { $_.displayName -in $AdminGroupsToCheckForInactiveUser } $allAdminMembers =@(); $ReqdAdminGroups | ForEach-Object{ $currentGroup = $_ $groupMembers = @(); if ([ControlHelper]::groupMembersResolutionObj.ContainsKey($currentGroup.descriptor) -and [ControlHelper]::groupMembersResolutionObj[$currentGroup.descriptor].count -gt 0) { $groupMembers += [ControlHelper]::groupMembersResolutionObj[$currentGroup.descriptor] } else { [ControlHelper]::FindGroupMembers($currentGroup.descriptor, $this.OrganizationContext.OrganizationName,"") $groupMembers += [ControlHelper]::groupMembersResolutionObj[$currentGroup.descriptor] } if($groupMembers.count -gt 0) { $groupMembers | ForEach-Object {$allAdminMembers += @( [PSCustomObject] @{ name = $_.displayName; mailAddress = $_.mailAddress; groupName = $currentGroup.displayName ; descriptor = $_.descriptor } )} } } $AdminUsersMasterList = @() $AdminUsersFailureCases = @() if($allAdminMembers.count -gt 0) { $groups = $allAdminMembers | Group-Object "mailAddress" $AdminUsersMasterList += foreach( $grpobj in $groups ){ $PrincipalName = $grpobj.name $OrgGroup = ($grpobj.group.groupName | select -Unique)-join ',' $DisplayName = $grpobj.group.name | select -Unique $date = "" $descriptor = $grpobj.group.descriptor | select -Unique [PSCustomObject]@{ PrincipalName = $PrincipalName ; DisplayName = $DisplayName ; Group = $OrgGroup ; LastAccessedDate = $date ; Descriptor = $descriptor} } $inactiveUsersWithAdminAccess =@() if($AdminUsersMasterList.count -gt 0) { $controlResult.AddMessage("`nFound $($AdminUsersMasterList.count) users in admin roles overall.") $controlResult.AddMessage("`nLooking for admin users who have not been active for $($inactivityPeriodInDays) days.") $currentObj = $null $AdminUsersMasterList | ForEach-Object{ try { if([Helpers]::CheckMember($_,"PrincipalName")) { $currentObj = $_ $url = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?%24filter=name%20eq%20%27{1}%27&%24orderBy=name%20Ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName), $_.PrincipalName; $response = @([WebRequestHelper]::InvokeGetWebRequest($url)); if([Helpers]::CheckMember($response[0],"members.lastAccessedDate")) { $members = @($response[0].members) if($members.count -gt 1) { $members = $members | where-object {$_.user.descriptor -eq $currentObj.Descriptor } } $dateobj = [datetime]::Parse($members[0].lastAccessedDate) if($dateobj -lt $thresholdDate ) { $formatLastRunTimeSpan = New-TimeSpan -Start $dateobj if(($formatLastRunTimeSpan).Days -gt 10000) { $_.LastAccessedDate = "User was never active" } else { $_.LastAccessedDate = $dateobj.ToString("d MMM yyyy") } $inactiveUsersWithAdminAccess += $_ } } } } catch { $controlResult.LogException($_) $AdminUsersFailureCases += $currentObj } } } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No user found with admin roles in the project.") } if($null -eq (Compare-Object -ReferenceObject $AdminUsersMasterList -DifferenceObject $AdminUsersFailureCases)) { $controlResult.AddMessage([VerificationResult]::Error, "Unable to fetch details of inactive users in admin role. Please run the scan with admin priveleges.") } elseif($inactiveUsersWithAdminAccess.count -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed,"Count of inactive users in admin roles: $($inactiveUsersWithAdminAccess.count) "); $controlResult.AddMessage("`nInactive admin user details:") $display = ($inactiveUsersWithAdminAccess|FT PrincipalName,DisplayName,Group,LastAccessedDate -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of inactive users: ", $inactiveUsersWithAdminAccess); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users in project admin roles found to be inactive for $($inactivityPeriodInDays) days."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not find the list of groups in the project.") } $controlResult.AddMessage("`nNote:`nThe following groups are considered for administrator privileges: `n$($AdminGroupsToCheckForInactiveUser|FT|Out-String)"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Not able to fetch project level groups") $controlResult.LogException($_) } } else{ $controlResult.AddMessage([VerificationResult]::Error, "List of admin groups for detecting inactive accounts is not defined in control setting of your organization."); } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForBuild([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $orgName = $($this.OrganizationContext.OrganizationName) $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $projectName = $this.ResourceContext.ResourceName; $permissionSetToken = $projectId if ([Helpers]::CheckMember($this.ControlSettings.Build, "RestrictedBroaderGroupsForBuild") -and [Helpers]::CheckMember($this.ControlSettings.Build, "ExcessivePermissionsForBroadGroups")) { $broaderGroups = $this.ControlSettings.Build.RestrictedBroaderGroupsForBuild $excessivePermissions = $this.ControlSettings.Build.ExcessivePermissionsForBroadGroups $namespacesApiURL = "https://dev.azure.com/{0}/_apis/securitynamespaces?api-version=6.0" -f $($orgName) $securityNamespacesObj = [WebRequestHelper]::InvokeGetWebRequest($namespacesApiURL); $buildSecurityNamespaceId = ($securityNamespacesObj | Where-Object { ($_.Name -eq "Build") -and ($_.actions.name -contains "ViewBuilds")}).namespaceId $buildURL = "https://dev.azure.com/$orgName/$projectName/_build" $allowPermissionBits = @(1,3) $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery/project/{1}?api-version=5.0-preview.1" -f $orgName, $projectId $inputbody = "{ 'contributionIds': [ 'ms.vss-admin-web.security-view-members-data-provider' ], 'dataProviderContext': { 'properties': { 'permissionSetId': '$buildSecurityNamespaceId', 'permissionSetToken': '$permissionSetToken', 'sourcePage': { 'url': '$buildURL', 'routeId': 'ms.vss-build-web.pipeline-details-route', 'routeValues': { 'project': '$projectName', 'viewname': 'details', 'controller': 'ContributedPage', 'action': 'Execute' } } } } }" | ConvertFrom-Json $responseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL, $inputbody); if ([Helpers]::CheckMember($responseObj[0], "dataProviders") -and ($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider') -and ([Helpers]::CheckMember($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider', "identities"))) { $broaderGroupsList = @($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider'.identities | Where-Object { $_.subjectKind -eq 'group' -and $broaderGroups -contains $_.displayName }) # $broaderGroupsList would be empty if none of its permissions are set i.e. all perms are 'Not Set'. if ($broaderGroupsList.Count) { $groupsWithExcessivePermissionsList = @() foreach ($broderGroup in $broaderGroupsList) { $broaderGroupInputbody = "{ 'contributionIds': [ 'ms.vss-admin-web.security-view-permissions-data-provider' ], 'dataProviderContext': { 'properties': { 'subjectDescriptor': '$($broderGroup.descriptor)', 'permissionSetId': '$buildSecurityNamespaceId', 'permissionSetToken': '$permissionSetToken', 'accountName': '$(($broderGroup.principalName).Replace('\','\\'))', 'sourcePage': { 'url': '$buildURL', 'routeId': 'ms.vss-build-web.pipeline-details-route', 'routeValues': { 'project': '$projectName', 'viewname': 'details', 'controller': 'ContributedPage', 'action': 'Execute' } } } } }" | ConvertFrom-Json #Web request to fetch RBAC permissions of broader groups on build. $broaderGroupResponseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL, $broaderGroupInputbody); $broaderGroupRBACObj = $broaderGroupResponseObj[0].dataProviders.'ms.vss-admin-web.security-view-permissions-data-provider'.subjectPermissions $excessivePermissionList = $broaderGroupRBACObj | Where-Object { $_.displayName -in $excessivePermissions } $excessiveEditPermissions = @() $excessivePermissionList | ForEach-Object { #effectivePermissionValue equals to 1 implies edit build pipeline perms is set to 'Allow'. Its value is 3 if it is set to Allow (inherited). This param is not available if it is 'Not Set'. if ([Helpers]::CheckMember($_, "effectivePermissionValue")) { if ($allowPermissionBits -contains $_.effectivePermissionValue) { $excessiveEditPermissions += $_ } } } if ($excessiveEditPermissions.Count -gt 0) { $excessivePermissionsGroupObj = @{} $excessivePermissionsGroupObj['Group'] = $broderGroup.principalName $excessivePermissionsGroupObj['ExcessivePermissions'] = $($excessiveEditPermissions.displayName -join ', ') $groupsWithExcessivePermissionsList += $excessivePermissionsGroupObj } } if ($groupsWithExcessivePermissionsList.count -gt 0) { #TODO: Do we need to put state object? $controlResult.AddMessage([VerificationResult]::Failed, "Build pipelines are set to inherit excessive permissions for a broad group of users at project level."); $formattedGroupsData = $groupsWithExcessivePermissionsList | Select @{l = 'Group'; e = { $_.Group} }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions } } $formattedBroaderGrpTable = ($formattedGroupsData | Out-String) $controlResult.AddMessage("`nList of groups : `n$formattedBroaderGrpTable"); $controlResult.AdditionalInfo += "List of excessive permissions on which broader groups have access: $($groupsWithExcessivePermissionsList.Group)."; } else { $controlResult.AddMessage([VerificationResult]::Passed, "Build pipelines are not allowed to inherit excessive permissions for a broad group of users at project level."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "Broader groups do not have access to the build pipelines at a project level."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch RBAC details of the build pipelines at a project level."); } $controlResult.AddMessage("`nNote:`nFollowing groups are considered 'broad groups':`n$($broaderGroups | FT | Out-String )`n"); $controlResult.AddMessage("`nFollowing permissions are considered 'excessive':`n$($excessivePermissions | FT | Out-String )`n"); } else { $controlResult.AddMessage([VerificationResult]::Error, "Broader groups or excessive permissions are not defined in control settings for your organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch RBAC details of the build pipelines at a project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForRelease([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $orgName = $($this.OrganizationContext.OrganizationName) $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $projectName = $this.ResourceContext.ResourceName; $permissionSetToken = $projectId if ([Helpers]::CheckMember($this.ControlSettings.Release, "RestrictedBroaderGroupsForRelease") -and [Helpers]::CheckMember($this.ControlSettings.Release, "ExcessivePermissionsForBroadGroups")) { $broaderGroups = $this.ControlSettings.Release.RestrictedBroaderGroupsForRelease $excessivePermissions = $this.ControlSettings.Release.ExcessivePermissionsForBroadGroups $namespacesApiURL = "https://dev.azure.com/{0}/_apis/securitynamespaces?api-version=6.0" -f $($orgName) $securityNamespacesObj = [WebRequestHelper]::InvokeGetWebRequest($namespacesApiURL); $releaseSecurityNamespaceId = ($securityNamespacesObj | Where-Object { ($_.Name -eq "ReleaseManagement") -and ($_.actions.name -contains "ViewReleaseDefinition")}).namespaceId $releaseURL = "https://dev.azure.com/$orgName/$projectName/_release" $allowPermissionBits = @(1,3) $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery/project/{1}?api-version=5.0-preview.1" -f $orgName, $projectId $inputbody = "{ 'contributionIds': [ 'ms.vss-admin-web.security-view-members-data-provider' ], 'dataProviderContext': { 'properties': { 'permissionSetId': '$releaseSecurityNamespaceId', 'permissionSetToken': '$permissionSetToken', 'sourcePage': { 'url': '$releaseURL', 'routeId': 'ms.vss-releaseManagement-web.hub-explorer-3-default-route', 'routeValues': { 'project': '$projectName', 'viewname': 'details', 'controller': 'ContributedPage', 'action': 'Execute' } } } } }" | ConvertFrom-Json # Todo - Add comments (Also for build, release controls) $responseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL, $inputbody); if ([Helpers]::CheckMember($responseObj[0], "dataProviders") -and ($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider') -and ([Helpers]::CheckMember($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider', "identities"))) { $broaderGroupsList = @($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider'.identities | Where-Object { $_.subjectKind -eq 'group' -and $broaderGroups -contains $_.displayName }) # $broaderGroupsList would be empty if none of its permissions are set i.e. all perms are 'Not Set'. if ($broaderGroupsList.Count) { $groupsWithExcessivePermissionsList = @() foreach ($broderGroup in $broaderGroupsList) { $broaderGroupInputbody = "{ 'contributionIds': [ 'ms.vss-admin-web.security-view-permissions-data-provider' ], 'dataProviderContext': { 'properties': { 'subjectDescriptor': '$($broderGroup.descriptor)', 'permissionSetId': '$releaseSecurityNamespaceId', 'permissionSetToken': '$permissionSetToken', 'accountName': '$(($broderGroup.principalName).Replace('\','\\'))', 'sourcePage': { 'url': '$releaseURL', 'routeId': 'ms.vss-releaseManagement-web.hub-explorer-3-default-route', 'routeValues': { 'project': '$projectName', 'viewname': 'details', 'controller': 'ContributedPage', 'action': 'Execute' } } } } }" | ConvertFrom-Json #Web request to fetch RBAC permissions of broader groups on release. $broaderGroupResponseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL, $broaderGroupInputbody); $broaderGroupRBACObj = $broaderGroupResponseObj[0].dataProviders.'ms.vss-admin-web.security-view-permissions-data-provider'.subjectPermissions $excessivePermissionList = $broaderGroupRBACObj | Where-Object { $_.displayName -in $excessivePermissions } $excessiveEditPermissions = @() $excessivePermissionList | ForEach-Object { #effectivePermissionValue equals to 1 implies edit release pipeline perms is set to 'Allow'. Its value is 3 if it is set to Allow (inherited). This param is not available if it is 'Not Set'. if ([Helpers]::CheckMember($_, "effectivePermissionValue")) { if ($allowPermissionBits -contains $_.effectivePermissionValue) { $excessiveEditPermissions += $_ } } } if ($excessiveEditPermissions.Count -gt 0) { $excessivePermissionsGroupObj = @{} $excessivePermissionsGroupObj['Group'] = $broderGroup.principalName $excessivePermissionsGroupObj['ExcessivePermissions'] = $($excessiveEditPermissions.displayName -join ', ') $groupsWithExcessivePermissionsList += $excessivePermissionsGroupObj } } if ($groupsWithExcessivePermissionsList.count -gt 0) { #TODO: Do we need to put state object? $controlResult.AddMessage([VerificationResult]::Failed, "Release pipelines are set to inherit excessive permissions for a broad group of users at project level."); $formattedGroupsData = $groupsWithExcessivePermissionsList | Select @{l = 'Group'; e = { $_.Group} }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions } } $formattedBroaderGrpTable = ($formattedGroupsData | Out-String) $controlResult.AddMessage("`nList of groups : `n$formattedBroaderGrpTable"); $controlResult.AdditionalInfo += "List of excessive permissions on which broader groups have access: $($groupsWithExcessivePermissionsList.Group)."; } else { $controlResult.AddMessage([VerificationResult]::Passed, "Broader Groups do not have excessive permissions on the release pipelines at a project level."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "Broader groups do not have access to the release pipelines at a project level."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch RBAC details of the pipelines at a project level."); } $controlResult.AddMessage("`nNote:`nFollowing groups are considered 'broad groups':`n$($broaderGroups | FT | Out-String)`n"); $controlResult.AddMessage("`nFollowing permissions are considered 'excessive':`n$($excessivePermissions | FT | Out-String)`n"); } else { $controlResult.AddMessage([VerificationResult]::Error, "Broader groups or excessive permissions are not defined in control settings for your organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch RBAC details of the release pipelines at a project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForSvcConn ([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $apiURL = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/{1}" -f $($this.OrganizationContext.OrganizationName), $($projectId); $serviceEndPointIdentity = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $restrictedGroups = @(); if ([Helpers]::CheckMember($this.ControlSettings, "ServiceConnection.RestrictedBroaderGroupsForSvcConn") ) { $restrictedBroaderGroupsForSvcConn = $this.ControlSettings.ServiceConnection.RestrictedBroaderGroupsForSvcConn; if (($serviceEndPointIdentity.Count -gt 0) -and [Helpers]::CheckMember($serviceEndPointIdentity, "identity")) { # match all the identities added on service connection with defined restricted list $roleAssignments = @(); $roleAssignments += ($serviceEndPointIdentity | Select-Object -Property @{Name="Name"; Expression = {$_.identity.displayName}},@{Name="Role"; Expression = {$_.role.displayName}}); #Checking where broader groups have user/admin permission for service connection $restrictedGroups += @($roleAssignments | Where-Object { $restrictedBroaderGroupsForSvcConn -contains $_.Name.split('\')[-1] -and ($_.Role -eq "Administrator" -or $_.Role -eq "User") }) $restrictedGroupsCount = $restrictedGroups.Count # fail the control if restricted group found on service connection if ($restrictedGroupsCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Service connections are set to inherit excessive permissions for a broad group of users at project level."); $controlResult.AddMessage("Count of broader groups: $($restrictedGroupsCount)`n") $formattedGroupsData = $restrictedGroups | Select @{l = 'Group'; e = { $_.Name} }, @{l = 'Role'; e = { $_.Role } } $formattedGroupsTable = ($formattedGroupsData | FT -AutoSize | Out-String) $controlResult.AddMessage("`nList of groups: ", $formattedGroupsTable) $controlResult.SetStateData("List of groups: ", $formattedGroupsData) $controlResult.AdditionalInfo += "Count of broader groups that have user/administrator access to service connection at a project level: $($restrictedGroupsCount)"; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have user/administrator access to service connection at a project level."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have user/administrator access to service connection at a project level."); } $controlResult.AddMessage("`nNote:`nThe following groups are considered 'broad' which should not have user/administrator privileges: `n$($restrictedBroaderGroupsForSvcConn | FT | out-string )`n"); } else { $controlResult.AddMessage([VerificationResult]::Error, "List of broader groups for service connection is not defined in control settings for your organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Unable to fetch service connections details. $($_)Please verify from portal that you are not granting global security groups access to service connections"); } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForAgentpool ([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed if ($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings, "AgentPool.RestrictedBroaderGroupsForAgentPool") -and [Helpers]::CheckMember($this.ControlSettings, "AgentPool.RestrictedRolesForBroaderGroupsInAgentPool")) { $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $apiURL = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/securityroles/scopes/distributedtask.agentqueuerole/roleassignments/resources/$($projectId)"; $agentPoolPermObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $restrictedBroaderGroupsForAgentPool = $this.ControlSettings.AgentPool.RestrictedBroaderGroupsForAgentPool; $restrictedRolesForBroaderGroupsInAgentPool = $this.ControlSettings.AgentPool.RestrictedRolesForBroaderGroupsInAgentPool; if (($agentPoolPermObj.Count -gt 0) -and [Helpers]::CheckMember($agentPoolPermObj, "identity")) { # match all the identities added on agentpool with defined restricted list $roleAssignments = @($agentPoolPermObj | Select-Object -Property @{Name="Name"; Expression = {$_.identity.displayName}},@{Name="Role"; Expression = {$_.role.displayName}}); # Checking whether the broader groups have User/Admin permissions $restrictedGroups = @($roleAssignments | Where-Object { $restrictedBroaderGroupsForAgentPool -contains $_.Name.split('\')[-1] -and ($restrictedRolesForBroaderGroupsInAgentPool -Contains $_.Role) }) $restrictedGroupsCount = $restrictedGroups.Count # fail the control if restricted group found on agentpool if ($restrictedGroupsCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Agent pools are set to inherit excessive permissions for a broad group of users at project level."); $controlResult.AddMessage([VerificationResult]::Failed, "Count of broader groups: $($restrictedGroupsCount)"); $formattedGroupsData = $restrictedGroups | Select @{l = 'Group'; e = { $_.Name} }, @{l = 'Role'; e = { $_.Role } } $formattedGroupsTable = ($formattedGroupsData | Out-String) $controlResult.AddMessage("`nList of groups: $formattedGroupsTable") $controlResult.SetStateData("List of groups: ", $restrictedGroups) $controlResult.AdditionalInfo += "Count of broader groups that have user/administrator access to agent pool at a project level: $($restrictedGroupsCount)"; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have user/administrator access to agent pool at a project level."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No groups have given access to agent pool at a project level."); } $controlResult.AddMessage("`nNote:`nThe following groups are considered 'broad' which should not have user/administrator privileges: `n$($restrictedBroaderGroupsForAgentPool | FT | out-string )`n"); } else { $controlResult.AddMessage([VerificationResult]::Error, "List of restricted broader groups and restricted roles for agent pools is not defined in the control settings for your organization policy."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the agent pool permissions at a project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForVarGrp ([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] if ($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings, "VariableGroup.RestrictedBroaderGroupsForVariableGroup") -and [Helpers]::CheckMember($this.ControlSettings, "VariableGroup.RestrictedRolesForBroaderGroupsInVariableGroup")) { $restrictedBroaderGroupsForVarGrp = $this.ControlSettings.VariableGroup.RestrictedBroaderGroupsForVariableGroup; $restrictedRolesForBroaderGroupsInvarGrp = $this.ControlSettings.VariableGroup.RestrictedRolesForBroaderGroupsInVariableGroup; #Fetch variable group RBAC $roleAssignments = @(); $url = 'https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.library/roleassignments/resources/{1}%240' -f $($this.OrganizationContext.OrganizationName), $($projectId); $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); if($responseObj.Count -gt 0) { $roleAssignments += ($responseObj | Select-Object -Property @{Name="Name"; Expression = {$_.identity.displayName}}, @{Name="Role"; Expression = {$_.role.displayName}}); } # Checking whether the broader groups have User/Admin permissions $restrictedGroups = @($roleAssignments | Where-Object { ($restrictedBroaderGroupsForVarGrp -contains $_.Name.split('\')[-1]) -and ($restrictedRolesForBroaderGroupsInvarGrp -contains $_.Role) }) $restrictedGroupsCount = $restrictedGroups.Count # fail the control if restricted group found on variable group if ($restrictedGroupsCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "`nCount of broader groups that have administrator access to variable group at a project level: $($restrictedGroupsCount)"); $formattedGroupsData = $restrictedGroups | Select @{l = 'Group'; e = { $_.Name} }, @{l = 'Role'; e = { $_.Role } } $formattedGroupsTable = ($formattedGroupsData | FT -AutoSize | Out-String) $controlResult.AddMessage("`nList of groups: `n$formattedGroupsTable") $controlResult.SetStateData("List of groups: ", $restrictedGroups) $controlResult.AdditionalInfo += "Count of broader groups that have administrator access to variable group at a project level: $($restrictedGroupsCount)"; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have administrator access to variable group at a project level."); } $controlResult.AddMessage("Note:`nThe following groups are considered 'broad' and should not have administrator privileges: `n$($restrictedBroaderGroupsForVarGrp | FT | out-string)"); } else { $controlResult.AddMessage([VerificationResult]::Error, "List of restricted broader groups and restricted roles for variable group is not defined in the control settings for your organization policy."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the variable group permissions at a project level."); $controlResult.LogException($_) } return $controlResult; } } # SIG # Begin signature block # MIIjoQYJKoZIhvcNAQcCoIIjkjCCI44CAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDJhVbanUpF5bVE # JcrKFNQ8oA2JeAsEUOvYB1iLdQ/fRaCCDYEwggX/MIID56ADAgECAhMzAAAB32vw # LpKnSrTQAAAAAAHfMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjAxMjE1MjEzMTQ1WhcNMjExMjAyMjEzMTQ1WjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQC2uxlZEACjqfHkuFyoCwfL25ofI9DZWKt4wEj3JBQ48GPt1UsDv834CcoUUPMn # s/6CtPoaQ4Thy/kbOOg/zJAnrJeiMQqRe2Lsdb/NSI2gXXX9lad1/yPUDOXo4GNw # PjXq1JZi+HZV91bUr6ZjzePj1g+bepsqd/HC1XScj0fT3aAxLRykJSzExEBmU9eS # yuOwUuq+CriudQtWGMdJU650v/KmzfM46Y6lo/MCnnpvz3zEL7PMdUdwqj/nYhGG # 3UVILxX7tAdMbz7LN+6WOIpT1A41rwaoOVnv+8Ua94HwhjZmu1S73yeV7RZZNxoh # EegJi9YYssXa7UZUUkCCA+KnAgMBAAGjggF+MIIBejAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUOPbML8IdkNGtCfMmVPtvI6VZ8+Mw # UAYDVR0RBEkwR6RFMEMxKTAnBgNVBAsTIE1pY3Jvc29mdCBPcGVyYXRpb25zIFB1 # ZXJ0byBSaWNvMRYwFAYDVQQFEw0yMzAwMTIrNDYzMDA5MB8GA1UdIwQYMBaAFEhu # ZOVQBdOCqhc3NyK1bajKdQKVMFQGA1UdHwRNMEswSaBHoEWGQ2h0dHA6Ly93d3cu # bWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY0NvZFNpZ1BDQTIwMTFfMjAxMS0w # Ny0wOC5jcmwwYQYIKwYBBQUHAQEEVTBTMFEGCCsGAQUFBzAChkVodHRwOi8vd3d3 # Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY0NvZFNpZ1BDQTIwMTFfMjAx # MS0wNy0wOC5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEAnnqH # tDyYUFaVAkvAK0eqq6nhoL95SZQu3RnpZ7tdQ89QR3++7A+4hrr7V4xxmkB5BObS # 0YK+MALE02atjwWgPdpYQ68WdLGroJZHkbZdgERG+7tETFl3aKF4KpoSaGOskZXp # TPnCaMo2PXoAMVMGpsQEQswimZq3IQ3nRQfBlJ0PoMMcN/+Pks8ZTL1BoPYsJpok # t6cql59q6CypZYIwgyJ892HpttybHKg1ZtQLUlSXccRMlugPgEcNZJagPEgPYni4 # b11snjRAgf0dyQ0zI9aLXqTxWUU5pCIFiPT0b2wsxzRqCtyGqpkGM8P9GazO8eao # mVItCYBcJSByBx/pS0cSYwBBHAZxJODUqxSXoSGDvmTfqUJXntnWkL4okok1FiCD # Z4jpyXOQunb6egIXvkgQ7jb2uO26Ow0m8RwleDvhOMrnHsupiOPbozKroSa6paFt # VSh89abUSooR8QdZciemmoFhcWkEwFg4spzvYNP4nIs193261WyTaRMZoceGun7G # CT2Rl653uUj+F+g94c63AhzSq4khdL4HlFIP2ePv29smfUnHtGq6yYFDLnT0q/Y+ # Di3jwloF8EWkkHRtSuXlFUbTmwr/lDDgbpZiKhLS7CBTDj32I0L5i532+uHczw82 # oZDmYmYmIUSMbZOgS65h797rj5JJ6OkeEUJoAVwwggd6MIIFYqADAgECAgphDpDS # AAAAAAADMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMK # V2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0 # IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0 # ZSBBdXRob3JpdHkgMjAxMTAeFw0xMTA3MDgyMDU5MDlaFw0yNjA3MDgyMTA5MDla # MH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS # ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMT # H01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTEwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQCr8PpyEBwurdhuqoIQTTS68rZYIZ9CGypr6VpQqrgG # OBoESbp/wwwe3TdrxhLYC/A4wpkGsMg51QEUMULTiQ15ZId+lGAkbK+eSZzpaF7S # 35tTsgosw6/ZqSuuegmv15ZZymAaBelmdugyUiYSL+erCFDPs0S3XdjELgN1q2jz # y23zOlyhFvRGuuA4ZKxuZDV4pqBjDy3TQJP4494HDdVceaVJKecNvqATd76UPe/7 # 4ytaEB9NViiienLgEjq3SV7Y7e1DkYPZe7J7hhvZPrGMXeiJT4Qa8qEvWeSQOy2u # M1jFtz7+MtOzAz2xsq+SOH7SnYAs9U5WkSE1JcM5bmR/U7qcD60ZI4TL9LoDho33 # X/DQUr+MlIe8wCF0JV8YKLbMJyg4JZg5SjbPfLGSrhwjp6lm7GEfauEoSZ1fiOIl # XdMhSz5SxLVXPyQD8NF6Wy/VI+NwXQ9RRnez+ADhvKwCgl/bwBWzvRvUVUvnOaEP # 6SNJvBi4RHxF5MHDcnrgcuck379GmcXvwhxX24ON7E1JMKerjt/sW5+v/N2wZuLB # l4F77dbtS+dJKacTKKanfWeA5opieF+yL4TXV5xcv3coKPHtbcMojyyPQDdPweGF # RInECUzF1KVDL3SV9274eCBYLBNdYJWaPk8zhNqwiBfenk70lrC8RqBsmNLg1oiM # CwIDAQABo4IB7TCCAekwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFEhuZOVQ # BdOCqhc3NyK1bajKdQKVMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1Ud # DwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFHItOgIxkEO5FAVO # 4eqnxzHRI4k0MFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwubWljcm9zb2Z0 # LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcmwwXgYIKwYBBQUHAQEEUjBQME4GCCsGAQUFBzAChkJodHRwOi8vd3d3Lm1p # Y3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcnQwgZ8GA1UdIASBlzCBlDCBkQYJKwYBBAGCNy4DMIGDMD8GCCsGAQUFBwIB # FjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2RvY3MvcHJpbWFyeWNw # cy5odG0wQAYIKwYBBQUHAgIwNB4yIB0ATABlAGcAYQBsAF8AcABvAGwAaQBjAHkA # XwBzAHQAYQB0AGUAbQBlAG4AdAAuIB0wDQYJKoZIhvcNAQELBQADggIBAGfyhqWY # 4FR5Gi7T2HRnIpsLlhHhY5KZQpZ90nkMkMFlXy4sPvjDctFtg/6+P+gKyju/R6mj # 82nbY78iNaWXXWWEkH2LRlBV2AySfNIaSxzzPEKLUtCw/WvjPgcuKZvmPRul1LUd # d5Q54ulkyUQ9eHoj8xN9ppB0g430yyYCRirCihC7pKkFDJvtaPpoLpWgKj8qa1hJ # Yx8JaW5amJbkg/TAj/NGK978O9C9Ne9uJa7lryft0N3zDq+ZKJeYTQ49C/IIidYf # wzIY4vDFLc5bnrRJOQrGCsLGra7lstnbFYhRRVg4MnEnGn+x9Cf43iw6IGmYslmJ # aG5vp7d0w0AFBqYBKig+gj8TTWYLwLNN9eGPfxxvFX1Fp3blQCplo8NdUmKGwx1j # NpeG39rz+PIWoZon4c2ll9DuXWNB41sHnIc+BncG0QaxdR8UvmFhtfDcxhsEvt9B # xw4o7t5lL+yX9qFcltgA1qFGvVnzl6UJS0gQmYAf0AApxbGbpT9Fdx41xtKiop96 # eiL6SJUfq/tHI4D1nvi/a7dLl+LrdXga7Oo3mXkYS//WsyNodeav+vyL6wuA6mk7 # r/ww7QRMjt/fdW1jkT3RnVZOT7+AVyKheBEyIXrvQQqxP/uozKRdwaGIm1dxVk5I # RcBCyZt2WwqASGv9eZ/BvW1taslScxMNelDNMYIVdjCCFXICAQEwgZUwfjELMAkG # A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx # HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9z # b2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAxMQITMwAAAd9r8C6Sp0q00AAAAAAB3zAN # BglghkgBZQMEAgEFAKCBsDAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgor # BgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQg/SiwFFcP # jJP+ocH0jluWmp0oqpE3sOrGv057IJeDx8cwRAYKKwYBBAGCNwIBDDE2MDSgFIAS # AE0AaQBjAHIAbwBzAG8AZgB0oRyAGmh0dHBzOi8vd3d3Lm1pY3Jvc29mdC5jb20g # MA0GCSqGSIb3DQEBAQUABIIBAGHOsi8FYj7xZ2vJRTV+meFoZ4rPq8yJa1UM7hOl # tqEKZc+2wO8sQheuUwAZ+emmD/FW6IgXf87fZbjdQ/o+HLl/8jRtDN7IqCA6vxTu # PJ7NH2mD+40kPpZ2AdARSn6LYckUf+nyOFSABW6QyiTxnHnx6vgwzsj9EMTHc+YP # jjsdXr6e0UvtKYri1h3ZJ6/2uNsBUPXwdlblXf80L11ulZuw7ZKZ1XIgdv/6Hb2K # LPkBa2v22E8s3a+HvO4Oh3TxcXXZ7TRI4RWUM09eD/l4X/5i2fcX9tnJD2xF0KAN # mIlZTzCoTi4xIn07cVkyFuSE/WKc+nMSM8mMJHjTWyS+f22hghL+MIIS+gYKKwYB # BAGCNwMDATGCEuowghLmBgkqhkiG9w0BBwKgghLXMIIS0wIBAzEPMA0GCWCGSAFl # AwQCAQUAMIIBWQYLKoZIhvcNAQkQAQSgggFIBIIBRDCCAUACAQEGCisGAQQBhFkK # AwEwMTANBglghkgBZQMEAgEFAAQg05WbdvS2w2O1nf9fZiNQ47GqFoIgQUvqiJKu # YHZt3lYCBmDUoAzZ7hgTMjAyMTA3MTUwODIwNTkuMDkxWjAEgAIB9KCB2KSB1TCB # 0jELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl # ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEtMCsGA1UECxMk # TWljcm9zb2Z0IElyZWxhbmQgT3BlcmF0aW9ucyBMaW1pdGVkMSYwJAYDVQQLEx1U # aGFsZXMgVFNTIEVTTjoyQUQ0LTRCOTItRkEwMTElMCMGA1UEAxMcTWljcm9zb2Z0 # IFRpbWUtU3RhbXAgU2VydmljZaCCDk0wggT5MIID4aADAgECAhMzAAABOPOUIdZh # vvApAAAAAAE4MA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQI # EwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3Nv # ZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBD # QSAyMDEwMB4XDTIwMTAxNTE3MjgyMFoXDTIyMDExMjE3MjgyMFowgdIxCzAJBgNV # BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w # HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xLTArBgNVBAsTJE1pY3Jvc29m # dCBJcmVsYW5kIE9wZXJhdGlvbnMgTGltaXRlZDEmMCQGA1UECxMdVGhhbGVzIFRT # UyBFU046MkFENC00QjkyLUZBMDExJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0 # YW1wIFNlcnZpY2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFufin # qV5wgzICAqNsrv4D+92pj7LWmaBciM7Bca8MCPLtoo+yn3vwUf5U8eH7diT21zQR # PfnnhttLtOs5t6NsAfxtDdQypPuxTv2eQAvEqrKSnaVh8j9m+sNUF5yzBvPW//0J # lxN2tJqKVs0MiDWVN9IDQsOF5tO3TYn5Hl4JmuF9d50JYk/WS3WPLQTnAlSvKpg6 # FeyuB7AZ1Cx0fZu+nkl9GKn6+DyvBUAnvxEdttPnFOh/6gUs4ICCYkbR7bILs8Ai # 0Sso8xaMzzqmRUpHpq2hUq4Dmgbh3g1aS2JTMpPZvKfCVWTHvi67qsXtT7nq50LD # CTaac2MP45cOHI8ZAgMBAAGjggEbMIIBFzAdBgNVHQ4EFgQU3+qt3OjvrFOxMvs5 # iMVgwZ5xwLUwHwYDVR0jBBgwFoAU1WM6XIoxkPNDe3xGG8UzaFqFbVUwVgYDVR0f # BE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJv # ZHVjdHMvTWljVGltU3RhUENBXzIwMTAtMDctMDEuY3JsMFoGCCsGAQUFBwEBBE4w # TDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0 # cy9NaWNUaW1TdGFQQ0FfMjAxMC0wNy0wMS5jcnQwDAYDVR0TAQH/BAIwADATBgNV # HSUEDDAKBggrBgEFBQcDCDANBgkqhkiG9w0BAQsFAAOCAQEAo3iwSFwLsb8NEpDL # 0si2jk4jlVlSBLdIEVms0eyv9nNHkyxqJjd5XGb3NmZuD6bi610KLwVxR09JwPqv # wWi18c7d0X8jquNd6P+wZs2/uHwewE9aYvdJ76Zn7/8hCsWLmuS2rGzotKQI+KuY # yFhnqFR8mA3g+C2Qb+bCpKoXTh/vgbwDYkfCEPOwsGLgxgFkNPgosn0oA6BZkCZL # g7cwBjHOx9uw3RayMyAMyrwHZepA3MgsRLCQA9EkpZd9fvcLoglLkwdZqsYiP4HK # AOzmnozbzDnexc9YAo0Cq3tkYFqykb6T+3fF9YCbx4PgKe8QpJUzxV9+vYfAb4JS # AAKmmjCCBnEwggRZoAMCAQICCmEJgSoAAAAAAAIwDQYJKoZIhvcNAQELBQAwgYgx # CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRt # b25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1p # Y3Jvc29mdCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTEwMDcw # MTIxMzY1NVoXDTI1MDcwMTIxNDY1NVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgT # Cldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29m # dCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENB # IDIwMTAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCpHQ28dxGKOiDs # /BOX9fp/aZRrdFQQ1aUKAIKF++18aEssX8XD5WHCdrc+Zitb8BVTJwQxH0EbGpUd # zgkTjnxhMFmxMEQP8WCIhFRDDNdNuDgIs0Ldk6zWczBXJoKjRQ3Q6vVHgc2/JGAy # WGBG8lhHhjKEHnRhZ5FfgVSxz5NMksHEpl3RYRNuKMYa+YaAu99h/EbBJx0kZxJy # GiGKr0tkiVBisV39dx898Fd1rL2KQk1AUdEPnAY+Z3/1ZsADlkR+79BL/W7lmsqx # qPJ6Kgox8NpOBpG2iAg16HgcsOmZzTznL0S6p/TcZL2kAcEgCZN4zfy8wMlEXV4W # nAEFTyJNAgMBAAGjggHmMIIB4jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU # 1WM6XIoxkPNDe3xGG8UzaFqFbVUwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEw # CwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZWy4/o # olxiaNE9lJBb186aGMQwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNy # b3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYt # MjMuY3JsMFoGCCsGAQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5t # aWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5j # cnQwgaAGA1UdIAEB/wSBlTCBkjCBjwYJKwYBBAGCNy4DMIGBMD0GCCsGAQUFBwIB # FjFodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vUEtJL2RvY3MvQ1BTL2RlZmF1bHQu # aHRtMEAGCCsGAQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAFAAbwBsAGkAYwB5AF8A # UwB0AGEAdABlAG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQAH5ohRDeLG # 4Jg/gXEDPZ2joSFvs+umzPUxvs8F4qn++ldtGTCzwsVmyWrf9efweL3HqJ4l4/m8 # 7WtUVwgrUYJEEvu5U4zM9GASinbMQEBBm9xcF/9c+V4XNZgkVkt070IQyK+/f8Z/ # 8jd9Wj8c8pl5SpFSAK84Dxf1L3mBZdmptWvkx872ynoAb0swRCQiPM/tA6WWj1kp # vLb9BOFwnzJKJ/1Vry/+tuWOM7tiX5rbV0Dp8c6ZZpCM/2pif93FSguRJuI57BlK # cWOdeyFtw5yjojz6f32WapB4pm3S4Zz5Hfw42JT0xqUKloakvZ4argRCg7i1gJsi # OCC1JeVk7Pf0v35jWSUPei45V3aicaoGig+JFrphpxHLmtgOR5qAxdDNp9DvfYPw # 4TtxCd9ddJgiCGHasFAeb73x4QDf5zEHpJM692VHeOj4qEir995yfmFrb3epgcun # Caw5u+zGy9iCtHLNHfS4hQEegPsbiSpUObJb2sgNVZl6h3M7COaYLeqN4DMuEin1 # wC9UJyH3yKxO2ii4sanblrKnQqLJzxlBTeCG+SqaoxFmMNO7dDJL32N79ZmKLxvH # Ia9Zta7cRDyXUHHXodLFVeNp3lfB0d4wwP3M5k37Db9dT+mdHhk4L7zPWAUu7w2g # UDXa7wknHNWzfjUeCLraNtvTX4/edIhJEqGCAtcwggJAAgEBMIIBAKGB2KSB1TCB # 0jELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl # ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEtMCsGA1UECxMk # TWljcm9zb2Z0IElyZWxhbmQgT3BlcmF0aW9ucyBMaW1pdGVkMSYwJAYDVQQLEx1U # aGFsZXMgVFNTIEVTTjoyQUQ0LTRCOTItRkEwMTElMCMGA1UEAxMcTWljcm9zb2Z0 # IFRpbWUtU3RhbXAgU2VydmljZaIjCgEBMAcGBSsOAwIaAxUAQLzrOzbQAqfNqS4h # IUsmylnmgfmggYMwgYCkfjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAN # BgkqhkiG9w0BAQUFAAIFAOSaJPYwIhgPMjAyMTA3MTUxMTA3MzRaGA8yMDIxMDcx # NjExMDczNFowdzA9BgorBgEEAYRZCgQBMS8wLTAKAgUA5Jok9gIBADAKAgEAAgJI # LgIB/zAHAgEAAgIRdjAKAgUA5Jt2dgIBADA2BgorBgEEAYRZCgQCMSgwJjAMBgor # BgEEAYRZCgMCoAowCAIBAAIDB6EgoQowCAIBAAIDAYagMA0GCSqGSIb3DQEBBQUA # A4GBAA2N+pSegjzqXFo4oje5yQPE4eAx1IRS2rvozPe7Qa/JCnVYC1zpAehgyt7u # 4I5n1z+YakmmsQeEKvvJQZdNJuCcWk3kqRe9PAjUoiB4hO4R1IxIiqMMYdJTVhrv # ZZq3BKhQoEfz3dVlbgYtprCoulrh/BPaYVCZhij3c1usqf3CMYIDDTCCAwkCAQEw # gZMwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT # B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE # AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAE485Qh1mG+8CkA # AAAAATgwDQYJYIZIAWUDBAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0B # CRABBDAvBgkqhkiG9w0BCQQxIgQgbhCScBPvY3/V9eaxUP9ZA0ryTKxKcDQywQaM # QMTVrmswgfoGCyqGSIb3DQEJEAIvMYHqMIHnMIHkMIG9BCBDQJNK+X9EKpJIYuIs # pu7uxtLeaOYI6k76K2HtFo+HKDCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w # IFBDQSAyMDEwAhMzAAABOPOUIdZhvvApAAAAAAE4MCIEIKMyT6zExMKBBY7Jzodr # Zx2ondydBNlGLgv8JGyjzNEoMA0GCSqGSIb3DQEBCwUABIIBAEviPDUSDDJArB9w # bujIFVKYx2SW4jMNz4Gfrf0f+Z134sljwDv2j1MZBJMVd2ELpHFUOuvJrQqqdV9Q # ZcuZJIrtL6NSxzYvbCNyqqPdvQHh1VfV59QHCqekHFNZYqFEQN3ujwjPOTgnfbEK # R7u2l6s97pL5xdi2jbD0cDgeg6MfVsHa2zhZRH+e/UygHu4inb5mudAy2HwrPCg0 # j9lOr9DHHeZwUhTcpzdE1ecy3aI0azRHek6AMMtsaUQOfYVig19zIXPPR5RVEBpC # LEOhnBgyLmRKDRiQHdqKoKroTvqyFeTZGZaQ8RzMl2/zH8rsm3JM4ysklZvb2BBh # A2/AAdk= # SIG # End signature block |