Framework/Core/SVT/ADO/ADO.Project.ps1
Set-StrictMode -Version Latest class Project: ADOSVTBase { [PSObject] $PipelineSettingsObj = $null hidden $PAMembers = @() hidden $BAMembers = @() hidden $Repos = $null hidden $GuestMembers = @() hidden $AllUsersInOrg = @() static $groupMappingsWithDescriptors = @{} #cache group names mapped with descriptor, to be used in auto fix 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 $controlResult.AdditionalInfoInCSV ="NA" 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'."); if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = [PSCustomObject]@{ "visibility" = "Public" }; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckProjectVisibilityAutomatedFix($controlResult); } } $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] CheckProjectVisibilityAutomatedFix([ControlResult] $controlResult){ try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix){ $body = '{"visibility":1,"state":-2}' $controlResult.AddMessage([VerificationResult]::Fixed, "Project visibility has been set to 'Enterprise'."); } else{ $body = '{"visibility":2,"state":-2}' $controlResult.AddMessage([VerificationResult]::Fixed, "Project visibility has been set to 'Public'."); } $url = "https://dev.azure.com/{0}/_apis/projects/{1}?api-version=5.1-preview.4" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.id $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method Patch -ContentType "application/json" -Headers $header -Body $body } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $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 $controlResult.AdditionalInfoInCSV ="NA" 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."); if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = [PSCustomObject]@{ "enforceSettableVar" = $false }; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckSettableQueueTimeAutomatedFix($controlResult); } } } else{ $controlResult.AddMessage([VerificationResult]::Error, "Pipeline settings could not be fetched for the project."); } return $controlResult } hidden [ControlResult] CheckSettableQueueTimeAutomatedFix([ControlResult] $controlResult){ try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix){ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceSettableVar":"true","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Settable at queue time has been disabled. 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{ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceSettableVar":"false","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Settable at queue time has been enabled. All variables can be set at queue time. It is set as '$($this.PipelineSettingsObj.enforceSettableVar.orgEnabled)' at organization scope."); } $body.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/settings" $body.dataProviderContext.properties.sourcePage.routeValues.project=$this.ResourceContext.ResourceName $url = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $this.OrganizationContext.OrganizationName $response = [WebRequestHelper]::InvokePostWebRequest($url,$body) } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckJobAuthZScope([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.enforceJobAuthScope.orgEnabled; $prjLevelScope = $this.PipelineSettingsObj.enforceJobAuthScope.enabled; $controlResult.AdditionalInfoInCSV ="NA" 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 ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = [PSCustomObject]@{ "enforceJobAuthScope" = $false }; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckJobAuthZScopeAutomatedFix($controlResult); } } 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] CheckJobAuthZScopeAutomatedFix([ControlResult] $controlResult){ try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix){ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceJobAuthScope":"true","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Job authorization scope has been limited to current project for non-release pipelines."); } else{ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceJobAuthScope":"false","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Job authorization scope has been set to project collection for non-release pipelines."); } $body.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/settings" $body.dataProviderContext.properties.sourcePage.routeValues.project=$this.ResourceContext.ResourceName $url = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $this.OrganizationContext.OrganizationName $response = [WebRequestHelper]::InvokePostWebRequest($url,$body) } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } 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 ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = [PSCustomObject]@{ "enforceJobAuthScopeForReleases" = $false }; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckJobAuthZReleaseScopeAutomatedFix($controlResult); } } 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] CheckJobAuthZReleaseScopeAutomatedFix([ControlResult] $controlResult){ try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix){ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceJobAuthScopeForReleases":"true","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Job authorization scope has been limited to current project for release pipelines."); } else{ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceJobAuthScopeForReleases":"false","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Job authorization scope has been set to project collection for release pipelines."); } $body.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/settings" $body.dataProviderContext.properties.sourcePage.routeValues.project=$this.ResourceContext.ResourceName $url = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $this.OrganizationContext.OrganizationName $response = [WebRequestHelper]::InvokePostWebRequest($url,$body) } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } 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 ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = [PSCustomObject]@{ "enforceReferencedRepoScopedToken" = $false }; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckAuthZRepoScopeAutomatedFix($controlResult); } } 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] CheckAuthZRepoScopeAutomatedFix([ControlResult] $controlResult){ try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix){ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceReferencedRepoScopedToken":"true","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Job authorization scope of pipelines has been limited to explicitly referenced Azure DevOps repositories."); } else{ $body = '{"contributionIds":["ms.vss-build-web.pipelines-general-settings-data-provider"],"dataProviderContext":{"properties":{"enforceReferencedRepoScopedToken":"false","sourcePage":{"url":"","routeId":"ms.vss-admin-web.project-admin-hub-route","routeValues":{"adminPivot":"settings","controller":"ContributedPage","action":"Execute","project":""}}}}}' | ConvertFrom-Json $controlResult.AddMessage([VerificationResult]::Fixed, "Job authorization scope of pipelines has been set to all Azure DevOps repositories in the authorized projects."); } $body.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceName)/_settings/settings" $body.dataProviderContext.properties.sourcePage.routeValues.project=$this.ResourceContext.ResourceName $url = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $this.OrganizationContext.OrganizationName $response = [WebRequestHelper]::InvokePostWebRequest($url,$body) } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } 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-group-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-group-members-data-provider', "identities")) { $usersObj.dataProviders."ms.vss-admin-web.org-admin-group-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((-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.") $controlResult.SetStateData("Count of Project Administrators: ",$TotalPAMembers) 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) } if ($svcAccounts.count -gt 0) { $controlResult.AddMessage("`nCount of Service accounts: $($svcAccounts.Count)") $display = ($svcAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) } } 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.AdditionalInfo += "Count of Project Administrators: " + $TotalPAMembers; #} } } else { $controlResult.AddMessage([VerificationResult]::Failed,"No Project Administrators are configured in the project."); } $controlResult.AdditionalInfoInCSV += "NumPAs: $($TotalPAMembers); "; $controlResult.AdditionalInfoInCSV += "MinPAReqd: $($this.ControlSettings.project.MinPAMembersPermissible);"; 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($this.PAMembers.Count -gt 0 -and [Helpers]::CheckMember($this.PAMembers[0],"mailAddress")) { $TotalPAMembers = $this.PAMembers.Count $controlResult.AddMessage("There are a total of $TotalPAMembers Project Administrators in your project.") $controlResult.SetStateData("Count of Project Administrators: ",$TotalPAMembers) } 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; $controlResult.AdditionalInfoInCSV += "TotalAdmin: $($TotalPAMembers); "; } if ($humanAccountsCount -gt 0) { $controlResult.AddMessage("`nCount of Human Administrators: $($humanAccountsCount)") $display = ($humanAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.AdditionalInfoInCSV += "HumanAdmin: $($humanAccountsCount); "; $humanIdentities = $humanAccounts | ForEach-Object { $_.displayName + ': ' + $_.mailAddress } | select-object -Unique -First 10; $controlResult.AdditionalInfoInCSV += "HumanAdminList: $($humanIdentities -join ' ; ');"; } if ($svcAccountsCount -gt 0) { $controlResult.AddMessage("`nCount of Service Accounts: $($svcAccountsCount)") $display = ($svcAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.AdditionalInfoInCSV += "ServiceAccount: $($svcAccountsCount); "; } } 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.AdditionalInfo += "Count of Project Administrators: " + $TotalPAMembers; $controlResult.AdditionalInfoInCSV += "TotalAdmin: $($TotalPAMembers); "; $identities = $this.PAMembers | ForEach-Object { $_.displayName + ': ' + $_.mailAddress } | select-object -Unique -First 10; $controlResult.AdditionalInfoInCSV += "AdminList: $($identities -join ' ; ');"; #} } } else { $controlResult.AddMessage([VerificationResult]::Verify,"No Project Administrators are configured in the project."); } return $controlResult } hidden [ControlResult] CheckMaxBACount([ControlResult] $controlResult){ $controlResult.VerificationResult = [verificationResult]::Failed; $TotalBAMembers = 0; if ($this.BAMembers.Count -eq 0) { $this.BAMembers += @([AdministratorHelper]::GetTotalBAMembers($this.OrganizationContext.OrganizationName,$this.ResourceContext.ResourceName)) } if($this.BAMembers.Count -gt 0) { $TotalBAMembers = $this.BAMembers.Count $controlResult.AddMessage("There are a total of $TotalBAMembers Build Administrators in your project.") $controlResult.SetStateData("Count of Build Administrators: ",$TotalBAMembers) if ([IdentityHelpers]::hasGraphAccess) { $SvcAndHumanAccounts = [IdentityHelpers]::DistinguishHumanAndServiceAccount($this.BAMembers, $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.MaxBAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of human build administrators configured are more than the approved limit: $($this.ControlSettings.Project.MaxBAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of human build administrators configured are within than the approved limit: $($this.ControlSettings.Project.MaxBAMembersPermissible)"); } $controlResult.AddMessage("Current set of Build Administrators: ") $controlResult.AdditionalInfo += "Count of Build Administrators: " + $TotalBAMembers; $controlResult.AdditionalInfoInCSV += "TotalAdmin: $($TotalBAMembers); "; if ($humanAccountsCount -gt 0) { $controlResult.AddMessage("`nCount of Human Build Administrators: $($humanAccountsCount)") $display = ($humanAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.AdditionalInfoInCSV += "HumanBuildAdmin: $($humanAccountsCount); "; $humanIdentities = $humanAccounts | ForEach-Object { $_.displayName + ': ' + $_.mailAddress } | select-object -Unique -First 10; $controlResult.AdditionalInfoInCSV += "HumanBuildAdminList: $($humanIdentities -join ' ; ');"; } if ($svcAccountsCount -gt 0) { $controlResult.AddMessage("`nCount of Service Accounts: $($svcAccountsCount)") $display = ($svcAccounts|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.AdditionalInfoInCSV += "ServiceAccount: $($svcAccountsCount); "; } } else { $controlResult.AddMessage([Constants]::graphWarningMessage+"`n"); $this.BAMembers = @($this.BAMembers | Select-Object displayName,mailAddress) if($TotalBAMembers -gt $this.ControlSettings.Project.MaxBAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of build administrators configured are more than the approved limit: $($this.ControlSettings.Project.MaxBAMembersPermissible)."); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of build administrators configured are within than the approved limit: $($this.ControlSettings.Project.MaxBAMembersPermissible)."); } $controlResult.AddMessage("Count of Build Administrators: $($TotalBAMembers)") $controlResult.AddMessage("Current set of Build Administrators: ") $display = ($this.PAMembers|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.AdditionalInfo += "Count of Build Administrators: " + $TotalBAMembers; $controlResult.AdditionalInfoInCSV += "TotalBuildAdmin: $($TotalBAMembers); "; $identities = $this.PAMembers | ForEach-Object { $_.displayName + ': ' + $_.mailAddress } | select-object -Unique -First 10; $controlResult.AdditionalInfoInCSV += "BuildAdminList: $($identities -join ' ; ');"; } } else { $controlResult.AddMessage([VerificationResult]::Verify,"No Build Administrators are configured in the project."); } return $controlResult } hidden [ControlResult] CheckSCALTForAdminMembers([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $adminGroupNames = @($this.ControlSettings.Project.GroupsToCheckForSCAltMembers); if($this.ControlSettings.Project.CheckExtendedGroupsForSCALTMembers){ $adminGroupNames+= @($this.ControlSettings.Project.ExtendedGroupsToCheckForSCAltMembers) } 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. Newly added in 2111 descriptor of user and direct memebership of groups for auto fix $groupMembers | ForEach-Object {$allAdminMembers += @( [PSCustomObject] @{ name = $_.displayName; mailAddress = $_.mailAddress; id = $_.originId; groupName = $adminGroups[$i].displayName; descriptor=$_.descriptor; DirectMemberOfGroup = $_.DirectMemberOfGroup } )} } # 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. $groups = $allAdminMembers | Group-Object "mailAddress" $groupedAdminMembers = @() $groupedAdminMembers +=foreach ($grpobj in $groups){ $directMemberOfGroups= $grpobj.Group.DirectMemberOfGroup | select -Unique $grp = ($grpobj.Group.groupName | select -Unique)-join ',' $name = $grpobj.Group.name | select -Unique $mailAddress = $grpobj.Group.mailAddress | select -Unique $id = $grpobj.Group.id | select -Unique $descriptor = $grpobj.Group.descriptor | select -Unique [PSCustomObject]@{name = $name;mailAddress = $mailAddress; id = $id;groupName = $grp; descriptor = $descriptor; directMemberOfGroups = $directMemberOfGroups} } $allAdminMembers = $groupedAdminMembers if($allAdminMembers.Count -gt 0) { $useGraphEvaluation = $false $useRegExEvaluation = $false if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "GraphThenRegEx") { if ([IdentityHelpers]::hasGraphAccess){ $useGraphEvaluation = $true } else { $useRegExEvaluation = $true } } $controlResult.AdditionalInfoInCSV += "NumAccounts: $($allAdminMembers.Count); " 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 $controlResult.AdditionalInfoInCSV += "NumNonALTAccounts: $($nonSCCount); " $totalAdminCount = $nonSCCount+$SCCount $controlResult.AddMessage("`nCount of accounts with admin privileges: $totalAdminCount"); if ($nonSCCount -gt 0) { if($this.ControlFixBackupRequired){ $backupSCMembers = $nonSCMembers | Select-Object name,mailAddress,groupName, directMemberOfGroups, descriptor #need to store total admin count along with users $adminCount = [PSCustomObject]@{ TotalAdminCount = $totalAdminCount } $controlResult.BackupControlState += $adminCount $controlResult.BackupControlState += $backupSCMembers } $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 -Width 512)); $controlResult.SetStateData("List of non-ALT accounts: ", $stateData); $controlResult.AdditionalInfo += "Count of non-ALT accounts with admin privileges: " + $nonSCCount; $nonSCaccounts = $nonSCMembers | ForEach-Object { $_.name + ': ' + $_.mailAddress + ';' } | select-object -Unique -First 10 $controlResult.AdditionalInfoInCSV += "First 10 Non_Alt_Admins: " + $nonSCaccounts -join ' ; ' $controlResult.AdditionalInfo += "First 10 Non_Alt_Admins: $($nonSCaccounts -join '; ')" } else { $controlResult.AddMessage([VerificationResult]::Passed, "All admin accounts are SC-ALT accounts."); $controlResult.AdditionalInfoInCSV = 'NA' ; } 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 -Width 512)); } } 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); $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 $totalAdminCount = $nonSCCount+$SCCount $controlResult.AddMessage("`nCount of accounts with admin privileges: $totalAdminCount"); $controlResult.AdditionalInfoInCSV += "NonALTAccounts: $($nonSCCount); " if ($nonSCCount -gt 0) { if($this.ControlFixBackupRequired){ $backupSCMembers = $nonSCMembers | Select-Object name,mailAddress,groupName, directMemberOfGroups, descriptor #need to store total admin count along with users $adminCount = [PSCustomObject]@{ TotalAdminCount = $totalAdminCount } $controlResult.BackupControlState += $adminCount $controlResult.BackupControlState += $backupSCMembers } $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 -Width 512)); $controlResult.SetStateData("List of non-ALT accounts: ", $stateData); $controlResult.AdditionalInfo += "Count of non-ALT accounts with admin privileges: " + $nonSCCount; $nonSCaccounts = $nonSCMembers | ForEach-Object { $_.name + ': ' + $_.mailAddress } | select-object -Unique -First 10 $controlResult.AdditionalInfoInCSV += "NonALTAccountsList: " + $nonSCaccounts -join ' ; ' } else { $controlResult.AddMessage([VerificationResult]::Passed, "All admin accounts are SC-ALT accounts."); $controlResult.AdditionalInfoInCSV += 'NA' ; } 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 -Width 512)); } } else { $controlResult.AddMessage([VerificationResult]::Manual, "Regular expressions for detecting SC-ALT account is not defined in the organization."); } } } 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."); $controlResult.AdditionalInfoInCSV += 'NA' ; } } 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."); } $controlResult.AddMessage("`nNote:`nThe following groups are considered administrator groups: `n$($adminGroupNames | FT | out-string)`n"); } else { $controlResult.AddMessage([VerificationResult]::Manual, "List of administrator groups for detecting non SC-ALT accounts is not defined in your project."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of groups in the project."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckSCALTForAdminMembersAutomatedFix([ControlResult] $controlResult){ try{ $RawDataObjForControlFix = @(); $RawDataObjForControlFix = @(([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject) $this.PublishCustomMessage("Note: Users which are part of admin groups via AAD groups will not be fixed using this command. In case the user is part of multiple AAD and non-AAD groups, they will be removed only from non-AAD groups.`n",[MessageType]::Warning); #first element of backup object contains total admin count $totalAdminCount = $RawDataObjForControlFix[0].TotalAdminCount #in case of only 1 PCA no need to remove the account if($totalAdminCount -eq 1){ $controlResult.AddMessage([VerificationResult]::Manual, "Only one admin has been found. To preserve accessibility to the project, automated fix will not be performed. Ensure there are atleast two Project Administrators."); return $controlResult } #rest of elements contain the non sc-alt users $nonSCAccounts = @($RawDataObjForControlFix[1..($RawDataObjForControlFix.Count-1)]) $user = [ContextHelper]::GetCurrentSessionUser(); #env variable for testing with non sc-alt account if($env:DontCheckALT){ $nonSCAccounts = @($nonSCAccounts | where-object {[Helpers]::CheckMember($_, "mailAddress") -and $user -notcontains $_.mailAddress}) $this.PublishCustomMessage("Note: The current user identity will not be removed from admin groups even if they are non SC-ALT.`n",[MessageType]::Warning); } else{ #in case the user is non sc-alt terminate the process here $useGraphEvaluation = $false $useRegExEvaluation = $false if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "GraphThenRegEx") { if ([IdentityHelpers]::hasGraphAccess){ $useGraphEvaluation = $true } else { $useRegExEvaluation = $true } } $isCurrentUserSCAlt=$false if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "Graph" -or $useGraphEvaluation){ $isCurrentUserSCAlt = [IdentityHelpers]::IsAltAccount($user, [IdentityHelpers]::graphAccessToken) } if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "RegEx" -or $useRegExEvaluation){ $controlResult.AddMessage([Constants]::graphWarningMessage); $matchToSCAlt = $this.ControlSettings.AlernateAccountRegularExpressionForOrg if (-not [string]::IsNullOrEmpty($matchToSCAlt)){ $isCurrentUserSCAlt= $user -match $matchToSCAlt } } if($isCurrentUserSCAlt -eq $false){ $this.PublishCustomMessage("The current user is a non SC-ALT account and hence is not allowed to perform the fix. Use -ResetCredentials and login as an SC-ALT acoount.`n",[MessageType]::Warning); $controlResult.AddMessage([VerificationResult]::Manual, "The current user is a non SC-ALT account and hence is not allowed to perform the fix. Use -ResetCredentials and login as an SC-ALT acoount."); return $controlResult } } #exclude users from fix if ($this.InvocationContext.BoundParameters["ExcludePrincipalId"]) { $excludePrincipalId = $this.InvocationContext.BoundParameters["ExcludePrincipalId"] $excludePrincipalId = $excludePrincipalId -Split ',' $nonSCAccounts = @($nonSCAccounts | where-object {$excludePrincipalId -notcontains $_.mailAddress }) } #add only specific users back into admin groups, applicable only in undofix if ($this.InvocationContext.BoundParameters["AddUsers"] -and $this.UndoFix){ $addUsers = $this.InvocationContext.BoundParameters["AddUsers"] $addUsers = $addUsers -Split ',' $nonSCAccounts = @($nonSCAccounts | where-object {$addUsers -contains $_.mailAddress }) } $nonSCAccountsCount = $nonSCAccounts.Count #in case all admins are non sc-alt (after removing current user and exclude principal ids) do not perform the fix if($nonSCAccountsCount -eq $totalAdminCount){ $controlResult.AddMessage([VerificationResult]::Manual, "All admins are non SC-ALT accounts. To preserve accessibility to the project, automated fix will not be performed. Ensure there is atleast one SC-ALT account as Project Administrator"); return $controlResult } $rmContext = [ContextHelper]::GetCurrentContext(); $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "",$rmContext.AccessToken))) if ($nonSCAccounts.Count -gt 0){ #to store users part of AAD groups $AADGroupAccounts=@() #to store users successfully deleted/added back $processedAccounts=@() #to store users that could not be deleted/added back due to any error (e.g. the groups have been deleted and we have stale backup, permission issues etc.) $unProcessedAccounts=@() if (-not $this.UndoFix){ foreach ($user in $nonSCAccounts){ foreach ($grp in $user.directMemberOfGroups){ #caching the group name and mapping it with the descriptors if(-not [Project]::groupMappingsWithDescriptors.ContainsKey($grp)){ $url = "https://vssps.dev.azure.com/{0}/_apis/identities?subjectDescriptors={1}&queryMembership=None&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $grp $response = [WebRequestHelper]::InvokeGetWebRequest($url); [Project]::groupMappingsWithDescriptors[$grp] = $response.providerDisplayName } #in case of an aad group, we can't remove users, store this seperately along with group name from cached object if($grp -match"aadgp.*"){ $AADGroupAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.name}}, @{N = "MailAddress"; E= {$_.mailAddress}}, @{N = "GroupName"; E= {$_.groupName}}, @{N = "DirectMemberOfAADGroup"; E= {[Project]::groupMappingsWithDescriptors[$grp]}} ) } else{ $url = "https://vssps.dev.azure.com/{0}/_apis/Graph/Memberships/{1}/{2}?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $user.descriptor, $grp try{ $webRequestResult = Invoke-WebRequest -Uri $url -Method Delete -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo)} $processedAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.name}}, @{N = "MailAddress"; E= {$_.mailAddress}}, @{N = "GroupName"; E= {$_.groupName}}, @{N = "DirectMemberOfNonAADGroup"; E= {[Project]::groupMappingsWithDescriptors[$grp]}}) } catch{ $unProcessedAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.name}}, @{N = "MailAddress"; E= {$_.mailAddress}}, @{N = "GroupName"; E= {$_.groupName}}, @{N = "DirectMemberOfNonAADGroup"; E= {[Project]::groupMappingsWithDescriptors[$grp]}}) } } } } if($processedAccounts.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Fixed, "Following non SC-ALT accounts have been removed from admin groups: "); } #if all users were part of AAD groups, we have not removed any user elseif($processedAccounts.Count -eq 0 -and $AADGroupAccounts.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Manual, "All admin accounts are a part of AAD group. Could not apply fix."); } } else{ foreach ($user in $nonSCAccounts){ foreach ($grp in $user.directMemberOfGroups){ if(-not [Project]::groupMappingsWithDescriptors.ContainsKey($grp)){ $url = "https://vssps.dev.azure.com/{0}/_apis/identities?subjectDescriptors={1}&queryMembership=None&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $grp $response = [WebRequestHelper]::InvokeGetWebRequest($url); [Project]::groupMappingsWithDescriptors[$grp] = $response.providerDisplayName } if($grp -match"aadgp.*"){ $AADGroupAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.name}}, @{N = "MailAddress"; E= {$_.mailAddress}}, @{N = "GroupName"; E= {$_.groupName}}, @{N = "DirectMemberOfAADGroup"; E= {[Project]::groupMappingsWithDescriptors[$grp]}} ) } else{ $url = "https://vssps.dev.azure.com/{0}/_apis/Graph/Memberships/{1}/{2}?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $user.descriptor, $grp try{ $webRequestResult = Invoke-WebRequest -Uri $url -Method Put -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo)} $processedAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.name}}, @{N = "MailAddress"; E= {$_.mailAddress}}, @{N = "GroupName"; E= {$_.groupName}}, @{N = "DirectMemberOfNonAADGroup"; E= {[Project]::groupMappingsWithDescriptors[$grp]}}) } catch{ $unProcessedAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.name}}, @{N = "MailAddress"; E= {$_.mailAddress}}, @{N = "GroupName"; E= {$_.groupName}}, @{N = "DirectMemberOfNonAADGroup"; E= {[Project]::groupMappingsWithDescriptors[$grp]}}) } } } } if($processedAccounts.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Fixed, "Following non SC-ALT accounts have been added back into admin groups: "); } } #to group accounts as a user can be a part of multiple groups, we will have duplicate entries due to group name resolution from the fix if($processedAccounts.Count -gt 0){ $groups = $processedAccounts | Group-Object "mailAddress" $groupedAdminMembers = @() $groupedAdminMembers +=foreach ($grpobj in $groups){ $grp = ($grpobj.Group.GroupName | select -Unique)-join ',' $name = $grpobj.Group.Name | select -Unique $mailAddress = $grpobj.Group.MailAddress | select -Unique $directMemberOfNonAADGroup=($grpobj.Group.DirectMemberOfNonAADGroup | select -Unique)-join ',' [PSCustomObject]@{Name = $name;MailAddress = $mailAddress; GroupName = $grp; DirectMemberOfNonAADGroup = $directMemberOfNonAADGroup} } $display = ($groupedAdminMembers | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) } #in case we have any accounts that errored out, override the result as error and give a list of accounts that could not be removed/added back if($unProcessedAccounts.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Error, "Following non SC-ALT accounts could not be fixed: "); $groups = $unProcessedAccounts | Group-Object "mailAddress" $groupedAdminMembers = @() $groupedAdminMembers +=foreach ($grpobj in $groups){ $grp = ($grpobj.Group.GroupName | select -Unique)-join ',' $name = $grpobj.Group.Name | select -Unique $mailAddress = $grpobj.Group.MailAddress | select -Unique $directMemberOfNonAADGroup=($grpobj.Group.DirectMemberOfNonAADGroup | select -Unique)-join ',' [PSCustomObject]@{Name = $name;MailAddress = $mailAddress; GroupName = $grp; DirectMemberOfNonAADGroup = $directMemberOfNonAADGroup} } $display = ($groupedAdminMembers | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) } if($AADGroupAccounts.Count -gt 0){ $groups = $AADGroupAccounts | Group-Object "mailAddress" $groupedAdminMembers = @() $groupedAdminMembers +=foreach ($grpobj in $groups){ $grp = ($grpobj.Group.GroupName | select -Unique)-join ',' $name = $grpobj.Group.Name | select -Unique $mailAddress = $grpobj.Group.MailAddress | select -Unique $directMemberOfAADGroup=($grpobj.Group.DirectMemberOfAADGroup | select -Unique)-join ',' [PSCustomObject]@{Name = $name;MailAddress = $mailAddress; GroupName = $grp; DirectMemberOfAADGroup = $directMemberOfAADGroup} } $display = ($groupedAdminMembers | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("Following accounts are part of admin groups via AAD groups and need to be removed manually: ") $controlResult.AddMessage($display) } } else{ $controlResult.AddMessage([VerificationResult]::Manual, "No admins found."); } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $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) { $OrgName = $this.OrganizationContext.OrganizationName $projName = $this.ResourceContext.ResourceName ## Checking Inactive Repos $isRepoActive = $false $repoRow = @() $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 $buildRow = @() $threshold = $this.ControlSettings.Build.BuildHistoryPeriodInDays $currentDate = Get-Date $thresholdDate = $currentDate.AddDays(-$threshold); $url = "https://dev.azure.com/$($OrgName)/$($projName)/_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 $releaseRow = @() $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/$($OrgName)/$($projName)/_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 $agentPoolRow = @() $thresholdLimit = $this.ControlSettings.AgentPool.AgentPoolHistoryPeriodInDays # Fetch All Agent Pools $url = "https://dev.azure.com/$($OrgName)/$($projName)/_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 $serviceConnectionRow = @() $thresholdLimit = $this.ControlSettings.ServiceConnection.ServiceConnectionHistoryPeriodInDays $url = "https://dev.azure.com/$($OrgName)/$($projName)/_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($_) } # Checking Work items $isWorkItemActive = $false $workItemsRow = @() $thresholdLimit = $this.ControlSettings.WorkItems.ThreshHoldDaysForWorkItemInactivity try { $url = "https://dev.azure.com/$($OrgName)/$($projName)/_apis/wit/wiql?timePrecision=$false&`$top=5&api-version=5.1" $body = '{"query": "Select * From WorkItems where [System.TeamProject] = @project AND [System.WorkItemType] <> '''' AND [Changed Date] > @today-'+$thresholdLimit+' ORDER BY [Changed Date] desc "}' | ConvertFrom-Json $res = [WebRequestHelper]::InvokePostWebRequest($url, $body) if([Helpers]::CheckMember($res[0],"workitems.id")) { $isWorkItemActive = $true $workItemsRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Work Items";"IsActive"="$($isWorkItemActive)"; "Additional Info" = "Work items are actively used in last $($thresholdLimit) days."}) } else { $workItemsRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Work Items";"IsActive"="$($isWorkItemActive)"; "Additional Info" = "Work items are not used in last $($thresholdLimit) days."}) } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch work item details."); $controlResult.LogException($_) } # Checking Artifacts: Feeds and Packages $isFeedAndPAckageActive = $false $thresholdLimit = $this.ControlSettings.FeedsAndPackages.ThreshHoldDaysForFeedsAndPackagesInactivity $thresholdDate = (Get-Date).AddDays(-$thresholdLimit) $feeds = @() $feedAndPackageRow = @() try { $url = "https://feeds.dev.azure.com/$($OrgName)/$($projName)/_apis/packaging/feeds?api-version=6.1-preview.1" $feeds = @([WebRequestHelper]::InvokeGetWebRequest($url)) if(-not ([Helpers]::CheckMember($feeds,"count") -and $feeds[0].count -eq 0 -and $feeds.Length -eq 1 )) { foreach( $feed in $feeds) { $url = "https://feeds.dev.azure.com/$($OrgName)/$($projName)/_apis/packaging/Feeds/$($feed.id)/packagechanges?api-version=6.1-preview.1" $allpackageObj= @([WebRequestHelper]::InvokeGetWebRequest($url)) if(-not ([Helpers]::CheckMember($allpackageObj,"count") -and $allpackageObj[0].count -eq 0 -and $allpackageObj.Length -eq 1 )) { $publishDatesofAllPackages = @($allpackageObj.packageChanges.packageVersionChange.packageVersion.publishDate | Sort-Object -Descending) if($publishDatesofAllPackages[0] -gt $thresholdDate) { $isFeedAndPAckageActive = $true break; } } } } else { $feedAndPackageRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Feeds and Packages";"IsActive"="$($isFeedAndPAckageActive)"; "Additional Info" = "Feed packages are not published in last $($thresholdLimit) days."}) } if($isFeedAndPackageActive) { $feedAndPackageRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Feeds and Packages";"IsActive"="$($isFeedAndPAckageActive)"; "Additional Info" = "Feed packages are published in last $($thresholdLimit) days."}) } else { $feedAndPackageRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Feeds and Packages";"IsActive"="$($isFeedAndPAckageActive)"; "Additional Info" = "Feed packages are not published in last $($thresholdLimit) days."}) } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch feeds and packages details."); $controlResult.LogException($_) } ## Checking Test Plans $isTestPlanActive = $false $thresholdLimit = $this.ControlSettings.TestPlans.ThreshHoldDaysForTestPlansInactivity $thresholdDate = (Get-Date).AddDays(-$thresholdLimit) $testPlanRow = @() try { $url = "https://dev.azure.com/$($OrgName)/$($projName)/_apis/testplan/plans?includePlanDetails=True&filterActivePlans=True&api-version=6.0-preview.1" $res = @([WebRequestHelper]::InvokeGetWebRequest($url)) if(-not ([Helpers]::CheckMember($res,"count") -and $res[0].count -eq 0 -and $res.Length -eq 1 )) { $Testplans = $res | Sort-Object -Property endDate -Descending $latestTestPlan = $Testplans[0] if([Helpers]::CheckMember($latestTestPlan,"endDate")) { if($latestTestPlan.endDate -gt $thresholdDate) { $url = "https://dev.azure.com/$($OrgName)/$($projName)/_apis/test/runs?includeRunDetails=true&planid=$($latestTestPlan.id)&api-version=6.0" $res = @([WebRequestHelper]::InvokeGetWebRequest($url)) if(-not ([Helpers]::CheckMember($res,"count") -and $res[0].count -eq 0 -and $res.Length -eq 1 )) { $runs = $res | Sort-Object -Property completedDate -Descending if( $runs[0].completedDate -gt $thresholdDate) { $isTestPlanActive = $true $testPlanRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Test Plans";"IsActive"="$($isTestPlanActive)"; "Additional Info" = "Test cases are executed in last $($thresholdLimit) days."}) } } else { $testPlanRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Test Plans";"IsActive"="$($isTestPlanActive)"; "Additional Info" = "No test cases are executed in last $($thresholdLimit) days."}) } } else { $testPlanRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Test Plans";"IsActive"="$($isTestPlanActive)"; "Additional Info" = "Test plans are not used in last $($thresholdLimit) days."}) } } else { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch test plan details."); } } else { $testPlanRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Test Plans";"IsActive"="$($isTestPlanActive)"; "Additional Info" = "No Test plan found in the project."}) } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch test cases details."); $controlResult.LogException($_) } ## Checking Wiki`s # We are only checking for project wiki not for code wiki as there is no portal or documented api for finding code wiki commits. $isProjectWikiActive = $false $thresholdLimit = $this.ControlSettings.Wikis.ThreshHoldDaysForWikisInactivity $thresholdDate = (Get-Date).AddDays(-$thresholdLimit) $wikiRow = @() try { $url = "https://dev.azure.com/$($OrgName)/$($projName)/_apis/wiki/wikis?api-version=6.0" $res = @([WebRequestHelper]::InvokeGetWebRequest($url)) if(-not ([Helpers]::CheckMember($res,"count") -and $res[0].count -eq 0 -and $res.Length -eq 1 )) { $projectWiki = @($res | Where-Object{ $_.type -eq "projectWiki" }) $url = "https://dev.azure.com/$($OrgName)/$($projName)/_apis/git/repositories/$($projectWiki.id)/Commits" $res = @([WebRequestHelper]::InvokeGetWebRequest($url)) if((-not ([Helpers]::CheckMember($res,"count") -and $res[0].count -eq 0 -and $res.Length -eq 1 )) -and ($res[0].author.date -gt $thresholdDate -or $res[0].committer.date -gt $thresholdDate)) { $IsprojectWikiActive = $true $wikiRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Project Wiki";"IsActive"="$($IsprojectWikiActive)"; "Additional Info" = "Project wiki is active in project."}) } else { $wikiRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Project Wiki";"IsActive"="$($IsprojectWikiActive)"; "Additional Info" = "Project wiki is inactive in project."}) } } else { $wikiRow = New-Object psobject -Property $([ordered] @{"Resource Type"="Project Wiki";"IsActive"="$($IsprojectWikiActive)"; "Additional Info" = "No project wiki is present in project."}) } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch project wiki 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;$workItemsRow;$feedAndPackageRow;$testPlanRow;$wikiRow) | Format-Table -AutoSize | Out-String -Width 512 $controlResult.AddMessage($table) $IsProjectActive = $isRepoActive -or $isBuildActive -or $isReleaseActive -or $isAgentPoolActive -or $isServiceConnectionActive -or $isWorkItemActive -or $isFeedAndPAckageActive -or $isTestPlanActive -or $isProjectWikiActive 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) -and ($isWorkItemActive -eq $true) -and ($isFeedAndPAckageActive -eq $true) -and ($isTestPlanActive -eq $true) -and ($isProjectWikiActive))) { $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) { 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; ContainerDescriptor = $obj.containerDescriptor; SubjectDescriptor = $_.user.descriptor; } } } } } 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) { if ($this.ControlFixBackupRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $formattedData } $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); $controlResult.AdditionalInfoInCSV += "NumAdminGuests: $($results.count); "; $UserList = $results | ForEach-Object { $_.DisplayName +': '+ $_.PrincipalName } | select-object -Unique -First 10; $controlResult.AdditionalInfoInCSV += "First 10 Guest_Admins: $($UserList -join ' ; ');"; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No guest users have admin roles in the project."); $controlResult.AdditionalInfoInCSV += "NA"; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No guest users found in organization."); $controlResult.AdditionalInfoInCSV += "NA"; } $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($_) } return $controlResult } hidden [ControlResult] CheckGuestUsersAccessInAdminRolesAutomatedFix([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); $RawDataObjForControlFix = @(([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject) if ($this.InvocationContext.BoundParameters["ExcludePrincipalId"]) { $excludePrincipalId = $this.InvocationContext.BoundParameters["ExcludePrincipalId"] $excludePrincipalId = $excludePrincipalId -Split ',' $RawDataObjForControlFix = @($RawDataObjForControlFix | where-object {$excludePrincipalId -notcontains $_.PrincipalName }) } $rmContext = [ContextHelper]::GetCurrentContext(); $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "",$rmContext.AccessToken))) if ($RawDataObjForControlFix.Count -gt 0) { if (-not $this.UndoFix) { foreach ($user in $RawDataObjForControlFix) { $uri = "https://vssps.dev.azure.com/{0}/_apis/graph/memberships/{1}/{2}?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $user.SubjectDescriptor , $user.ContainerDescriptor $webRequestResult = Invoke-WebRequest -Uri $uri -Method Delete -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo)} } $controlResult.AddMessage([VerificationResult]::Fixed, "Admin permission for these users has been removed: "); } else { foreach ($user in $RawDataObjForControlFix) { $uri = "https://vssps.dev.azure.com/{0}/_apis/graph/memberships/{1}/{2}?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $user.SubjectDescriptor , $user.ContainerDescriptor $webRequestResult = Invoke-RestMethod -Uri $uri -Method Put -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo) } #-Body $body } $controlResult.AddMessage([VerificationResult]::Fixed, "Admin permission for these users has been restored: "); } $display = ($RawDataObjForControlFix | FT PrincipalName,Name,Group -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) } else { $controlResult.AddMessage([VerificationResult]::Manual, "No guest users found."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckInactiveUsersInAdminRoles([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.ControlSettings.Project.AdminGroupsToCheckForInactiveUser) { try { $AdminGroupsToCheckForInactiveUser = @($this.ControlSettings.Project.AdminGroupsToCheckForInactiveUser) if($this.ControlSettings.Project.CheckExtendedGroupsForInactiveUser){ $AdminGroupsToCheckForInactiveUser+= @($this.ControlSettings.Project.ExtendedGroupsToCheckForInactiveUser) $this.PublishCustomMessage("You have requested to scan for extended groups as well. This may take some time.`n",[MessageType]::Warning) } $inactiveUsersWithAdminAccess = @() $neverActiveUsersWithAdminAccess = @() $inactivityPeriodInDays = 90 if($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) { $member = [ControlHelper]::groupMembersResolutionObj[$currentGroup.descriptor] $groupMembers += $member } else { [ControlHelper]::FindGroupMembers($currentGroup.descriptor, $this.OrganizationContext.OrganizationName,"") $member = [ControlHelper]::groupMembersResolutionObj[$currentGroup.descriptor] $groupMembers += $member } if($groupMembers.count -gt 0) { $groupMembers | ForEach-Object {$allAdminMembers += @( [PSCustomObject] @{ name = $_.displayName; mailAddress = $_.mailAddress; groupName = $currentGroup.displayName ; descriptor = $_.descriptor ; subjectdescriptor = $_.DirectMemberOfGroup } )} } } $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 = "" $createdDate = "" $descriptor = $grpobj.group.descriptor | select -Unique $subDescriptor = $grpobj.group.subjectdescriptor | select -Unique [PSCustomObject]@{ PrincipalName = $PrincipalName ; DisplayName = $DisplayName ; Group = $OrgGroup ; LastAccessedDate = $date ; Descriptor = $descriptor; subjectdescriptor = $subDescriptor; DateCreated = $createdDate } } $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 } } if([ContextHelper]::PSVersion -gt 5) { $dateobj = $members[0].lastAccessedDate } else { $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" $neverActiveUsersWithAdminAccess += $_ if([Helpers]::CheckMember($members[0],"dateCreated")) { if([ContextHelper]::PSVersion -gt 5) { $_.dateCreated = ([datetime] $members[0].dateCreated).ToString("d MMM yyyy") } else { $_.dateCreated = [datetime]::Parse($members[0].dateCreated) } } } else { $_.LastAccessedDate = $dateobj #.ToString("d MMM yyyy"), date object is needed to sort users based on datetime. $inactiveUsersWithAdminAccess += $_ } } } } } catch { $controlResult.LogException($_) $AdminUsersFailureCases += $currentObj } } } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No user found with admin roles in the project.") $controlResult.AdditionalInfoInCSV += 'NA' ; } $inactiveUsersCount = $inactiveUsersWithAdminAccess.count $neverActiveUsersCount = $neverActiveUsersWithAdminAccess.count 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(($inactiveUsersCount -gt 0) -or ($neverActiveUsersCount -gt 0)) { $totalInactiveUsers = @() $totalInactiveUsers += @($inactiveUsersWithAdminAccess | Select-Object PrincipalName,DisplayName,Group,LastAccessedDate,DateCreated) $totalInactiveUsers += @($neverActiveUsersWithAdminAccess | Select-Object PrincipalName,DisplayName,Group,LastAccessedDate,DateCreated) $totalInactiveUsersCount = $totalInactiveUsers.Count $controlResult.AddMessage([VerificationResult]::Failed,"Total number of inactive users present in the admin roles: $($totalInactiveUsersCount)"); $controlResult.AdditionalInfo += "Total number of inactive users present in the admin toles: " + $totalInactiveUsersCount; $controlResult.SetStateData("Inactive users list: ", $totalInactiveUsers); $controlResult.AdditionalInfoInCSV += "NumInactiveUsers: $totalInactiveUsersCount ; "; if ($this.ControlFixBackupRequired) { $backupObj = @() $backupObj += $inactiveUsersWithAdminAccess $backupObj += $neverActiveUsersWithAdminAccess | Select-Object PrincipalName,DisplayName,Group, LastAccessedDate, Descriptor,SubjectDescriptor #Data object that will be required to fix the control $controlResult.BackupControlState = $backupObj | Select-Object -property PrincipalName,DisplayName,Group,Descriptor,SubjectDescriptor } if($inactiveUsersCount -gt 0) { $inactiveUsersWithAdminAccess = @($inactiveUsersWithAdminAccess | Select-Object PrincipalName,DisplayName,Group,@{Name="InactiveFromDays"; Expression = {((Get-Date) -($_.LastAccessedDate)).Days}}) $inactiveUsersWithAdminAccess = $inactiveUsersWithAdminAccess| Sort-Object InactiveFromDays -Descending $controlResult.AddMessage("`nCount of users found inactive for $($inactivityPeriodInDays) days in admin roles: $($inactiveUsersCount) "); $controlResult.AddMessage("Inactive admin user details:") $display = $inactiveUsersWithAdminAccess|FT -AutoSize | Out-String -Width 512 $controlResult.AddMessage($display) } if($neverActiveUsersCount -gt 0) { $neverActiveUsersWithAdminAccess = @($neverActiveUsersWithAdminAccess| Sort-Object DateCreated | Select-Object PrincipalName,DisplayName,Group,LastAccessedDate,@{Name="DateCreated";Expression = {([datetime] $_.DateCreated).ToString("d MMM yyyy")}}) $controlResult.AddMessage("Count of users found never active in admin roles: $($neverActiveUsersCount) "); $controlResult.AddMessage("Never active admin user details:") $display = $neverActiveUsersWithAdminAccess|FT -AutoSize | Out-String -Width 512 $controlResult.AddMessage($display) } if($totalInactiveUsersCount -gt 0) { $inactiveUsersList = $totalInactiveUsers | Select-Object DisplayName, PrincipalName, @{Name="InactiveFromDays"; Expression = { if ($_.LastAccessedDate -eq "User was never active"){return (((Get-Date) - $_.dateCreated)).Days} else {return (((Get-Date) - $_.LastAccessedDate).Days)} }}, @{Name="NACTag"; Expression = { if ($_.LastAccessedDate -eq "User was never active"){return " (NAC)"} }} | Sort-Object InactiveFromDays -Desc $UserList = $inactiveUsersList | ForEach-Object { $_.DisplayName +': '+ $_.PrincipalName +': '+ $_.InactiveFromDays +" days" + $_.NACTag} | select-object -Unique -First 10; $controlResult.AdditionalInfoInCSV += "First 10 InactiveUsers: $($UserList -join ' ; '); "; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users in project admin roles found to be inactive for $($inactivityPeriodInDays) days."); $controlResult.AdditionalInfoInCSV += 'NA' ; } } 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] CheckInactiveUsersInAdminRolesAutomatedFix([ControlResult] $controlResult) { $this.PublishCustomMessage("Note: Users which are part of admin groups via AAD group will not be fixed using this command.`n",[MessageType]::Warning); try{ $RawDataObjForControlFix = @(); $RawDataObjForControlFix = @(([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject) if ($this.InvocationContext.BoundParameters["ExcludePrincipalId"]) { $excludePrincipalId = $this.InvocationContext.BoundParameters["ExcludePrincipalId"] $excludePrincipalId = $excludePrincipalId -Split ',' $RawDataObjForControlFix = @($RawDataObjForControlFix | where-object {$excludePrincipalId -notcontains $_.PrincipalName }) } $rmContext = [ContextHelper]::GetCurrentContext(); $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "",$rmContext.AccessToken))) if ($RawDataObjForControlFix.Count -gt 0) { #to store users part of AAD groups $AADGroupAccounts=@() #to store users successfully deleted/added back $processedAccounts=@() if (-not $this.UndoFix) { foreach ($user in $RawDataObjForControlFix) { foreach($groupDescriptor in $user.subjectDescriptor) { #caching the group name and mapping it with the descriptors if(-not [Organization]::groupMappingsWithDescriptors.ContainsKey($groupDescriptor)){ $url = "https://vssps.dev.azure.com/{0}/_apis/identities?subjectDescriptors={1}&queryMembership=None&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $groupDescriptor $response = [WebRequestHelper]::InvokeGetWebRequest($url); [Organization]::groupMappingsWithDescriptors[$groupDescriptor] = $response.providerDisplayName } #in case of an aad group, we can't remove users, store this seperately along with group name from cached object if($groupDescriptor -match"aadgp.*"){ $AADGroupAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.DisplayName}}, @{N = "MailAddress"; E= {$_.PrincipalName}}, @{N = "GroupName"; E= {$_.Group}}, @{N = "DirectMemberOfAADGroup"; E= {[Organization]::groupMappingsWithDescriptors[$groupDescriptor]}} ) } else{ $uri = "https://vssps.dev.azure.com/{0}/_apis/graph/memberships/{1}/{2}?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $user.Descriptor , $groupDescriptor $webRequestResult = Invoke-WebRequest -Uri $uri -Method Delete -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo)} $processedAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.DisplayName}}, @{N = "MailAddress"; E= {$_.PrincipalName}}, @{N = "GroupName"; E= {$_.Group}}, @{N = "DirectMemberOfNonAADGroup"; E= {[Organization]::groupMappingsWithDescriptors[$groupDescriptor]}}) } } } if($processedAccounts.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Fixed, "Admin permissions for these users has been removed: "); } } else { foreach ($user in $RawDataObjForControlFix) { foreach($groupDescriptor in $user.subjectDescriptor) { #caching the group name and mapping it with the descriptors if(-not [Organization]::groupMappingsWithDescriptors.ContainsKey($groupDescriptor)){ $url = "https://vssps.dev.azure.com/{0}/_apis/identities?subjectDescriptors={1}&queryMembership=None&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $groupDescriptor $response = [WebRequestHelper]::InvokeGetWebRequest($url); [Organization]::groupMappingsWithDescriptors[$groupDescriptor] = $response.providerDisplayName } #in case of an aad group, we can't remove users, store this seperately along with group name from cached object if($groupDescriptor -match"aadgp.*"){ $AADGroupAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.DisplayName}}, @{N = "MailAddress"; E= {$_.PrincipalName}}, @{N = "GroupName"; E= {$_.Group}}, @{N = "DirectMemberOfAADGroup"; E= {[Organization]::groupMappingsWithDescriptors[$groupDescriptor]}} ) } else{ $uri = "https://vssps.dev.azure.com/{0}/_apis/graph/memberships/{1}/{2}?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $user.Descriptor , $groupDescriptor $webRequestResult = Invoke-RestMethod -Uri $uri -Method Put -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo) } $processedAccounts+= @($user | Select-Object -property @{N = "Name"; E= {$_.DisplayName}}, @{N = "MailAddress"; E= {$_.PrincipalName}}, @{N = "GroupName"; E= {$_.Group}}, @{N = "DirectMemberOfNonAADGroup"; E= {[Organization]::groupMappingsWithDescriptors[$groupDescriptor]}}) } } } if($processedAccounts.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Fixed,"Admin permissions for these users has been restored: "); } } #to group accounts as a user can be a part of multiple groups, we will have duplicate entries due to group name resolution from the fix if($processedAccounts.Count -gt 0){ $groups = $processedAccounts | Group-Object "mailAddress" $groupedAdminMembers = @() $groupedAdminMembers +=foreach ($grpobj in $groups){ $grp = ($grpobj.Group.GroupName | select -Unique)-join ',' $name = $grpobj.Group.Name | select -Unique $mailAddress = $grpobj.Group.MailAddress | select -Unique $directMemberOfNonAADGroup=($grpobj.Group.DirectMemberOfNonAADGroup | select -Unique)-join ',' [PSCustomObject]@{Name = $name;MailAddress = $mailAddress; GroupName = $grp; DirectMemberOfNonAADGroup = $directMemberOfNonAADGroup} } $display = ($groupedAdminMembers | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) } if($AADGroupAccounts.Count -gt 0){ $groups = $AADGroupAccounts | Group-Object "mailAddress" $groupedAdminMembers = @() $groupedAdminMembers +=foreach ($grpobj in $groups){ $grp = ($grpobj.Group.GroupName | select -Unique)-join ',' $name = $grpobj.Group.Name | select -Unique $mailAddress = $grpobj.Group.MailAddress | select -Unique $directMemberOfAADGroup=($grpobj.Group.DirectMemberOfAADGroup | select -Unique)-join ',' [PSCustomObject]@{Name = $name;MailAddress = $mailAddress; GroupName = $grp; DirectMemberOfAADGroup = $directMemberOfAADGroup} } $display = ($groupedAdminMembers | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("Following accounts are part of admin groups via AAD groups and need to be removed manually: ") $controlResult.AddMessage($display) } } else { $controlResult.AddMessage([VerificationResult]::Manual, "No guest users found."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForBuild([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $orgName = $($this.OrganizationContext.OrganizationName) $projectId = $this.ResourceContext.ResourceDetails.Id #($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $projectName = $this.ResourceContext.ResourceName; $permissionSetToken = $projectId $restrictedBroaderGroups = @{} $broaderGroups = $this.ControlSettings.Build.RestrictedBroaderGroupsForBuild $broaderGroups.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } $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) if ($this.ControlSettings.Build.CheckForInheritedPermissions) { #allow permission bit for inherited permission is '3' $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 $restrictedBroaderGroups.keys -contains $_.displayName }) # $broaderGroupsList would be empty if none of its permissions are set i.e. all perms are 'Not Set'. if ($broaderGroupsList.Count) { $groupsWithExcessivePermissionsList = @() $filteredBroaderGroupList = @() 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 $restrictedBroaderGroups[$broderGroup.principalName.split('\')[-1]] } $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 ', ') $excessivePermissionsGroupObj['Descriptor'] = $broderGroup.sid $excessivePermissionsGroupObj['PermissionSetToken'] = $permissionSetToken $excessivePermissionsGroupObj['PermissionSetId'] = $buildSecurityNamespaceId $groupsWithExcessivePermissionsList += $excessivePermissionsGroupObj $filteredBroaderGroupList += $broderGroup } } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $filteredBroaderGroupList.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($filteredBroaderGroupList, $false)) $groupsWithExcessivePermissionsList = @($groupsWithExcessivePermissionsList | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Group}) } 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 | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups : `n$formattedBroaderGrpTable"); $controlResult.AdditionalInfo += "List of excessive permissions on which broader groups have access: $($groupsWithExcessivePermissionsList.Group)."; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $groupsWithExcessivePermissionsList; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupInheritanceSettingsForBuildAutomatedFix($controlResult); } $groups = $groupsWithExcessivePermissionsList | ForEach-Object { $_.Group + ': ' + $_.ExcessivePermissions -join ',' } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' } 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."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("`nNote:`nFollowing groups are considered 'broad groups':`n$($displayObj | FT -AutoSize | Out-String -Width 512)"); } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch RBAC details of the build pipelines at a project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForBuildAutomatedFix([ControlResult] $controlResult) { try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix) { $rmContext = [ContextHelper]::GetCurrentContext(); $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "",$rmContext.AccessToken))) foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split "," foreach ($excessivePermission in $excessivePermissions) { $roleId = [int][BuildPermissions] $excessivePermission.Replace(" ",""); $url = "https://dev.azure.com/{0}/_apis/Permissions/{1}/{2}?descriptor=Microsoft.TeamFoundation.Identity;{3}&token={4}&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $($RawDataObjForControlFix[0].PermissionSetId), $roleId,$($identity.Descriptor), $($identity.PermissionSetToken) $response = Invoke-WebRequest -Uri $url -Method Delete -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo)} #API takes some time to reflect the changes, if a new API call is made before that this permission might not be reflected even though status code is 200 if($response.StatusCode -eq 200){ Start-Sleep -seconds 1 } } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Allow" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Not set" } } else { foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split "," foreach ($excessivePermission in $excessivePermissions) { $roleId = [int][BuildPermissions] $excessivePermission.Replace(" ",""); $body = "{ 'token': '$($identity.PermissionSetToken)', 'merge': true, 'accessControlEntries' : [{ 'descriptor' : 'Microsoft.TeamFoundation.Identity;$($identity.Descriptor)', 'allow':$($roleId), 'deny':0 }] }" | ConvertFrom-Json $url = "https://dev.azure.com/{0}/_apis/AccessControlEntries/{1}?api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $RawDataObjForControlFix[0].PermissionSetId [WebRequestHelper]:: InvokePostWebRequest($url,$body) } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Not set" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Allow" } } $controlResult.AddMessage([VerificationResult]::Fixed, "Permissions for broader groups have been changed as below: "); $formattedGroupsData = $RawDataObjForControlFix | Select @{l = 'Group'; e = { $_.Group } }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions }}, @{l = 'OldPermission'; e = { $_.OldPermission }}, @{l = 'NewPermission'; e = { $_.NewPermission } } $display = ($formattedGroupsData | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`n$display"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForRelease([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $orgName = $($this.OrganizationContext.OrganizationName) $projectId = $this.ResourceContext.ResourceDetails.Id $projectName = $this.ResourceContext.ResourceName; $permissionSetToken = $projectId $restrictedBroaderGroups = @{} $broaderGroups = $this.ControlSettings.Release.RestrictedBroaderGroupsForRelease $broaderGroups.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } $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) if ($this.ControlSettings.Release.CheckForInheritedPermissions) { #allow permission bit for inherited permission is '3' $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 $restrictedBroaderGroups.keys -contains $_.displayName }) # $broaderGroupsList would be empty if none of its permissions are set i.e. all perms are 'Not Set'. if ($broaderGroupsList.Count) { $groupsWithExcessivePermissionsList = @() $filteredBroaderGroupList = @() 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 $restrictedBroaderGroups[$broderGroup.principalName.split('\')[-1]] } $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 ', ') $excessivePermissionsGroupObj['Descriptor'] = $broderGroup.sid $excessivePermissionsGroupObj['PermissionSetToken'] = $permissionSetToken $excessivePermissionsGroupObj['PermissionSetId'] = $releaseSecurityNamespaceId $groupsWithExcessivePermissionsList += $excessivePermissionsGroupObj $filteredBroaderGroupList += $broderGroup } } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $filteredBroaderGroupList.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($filteredBroaderGroupList, $false)) $groupsWithExcessivePermissionsList = @($groupsWithExcessivePermissionsList | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Group}) } 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 | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups : `n$formattedBroaderGrpTable"); $controlResult.AdditionalInfo += "List of excessive permissions on which broader groups have access: $($groupsWithExcessivePermissionsList.Group)."; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $groupsWithExcessivePermissionsList; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupInheritanceSettingsForReleaseAutomatedFix($controlResult); } $groups = $groupsWithExcessivePermissionsList | ForEach-Object { $_.Group + ': ' + $_.ExcessivePermissions -join ',' } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' } 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."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("`nNote:`nFollowing groups are considered 'broad groups':`n$($displayObj | FT -AutoSize | Out-String -Width 512)"); } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch RBAC details of the release pipelines at a project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForReleaseAutomatedFix([ControlResult] $controlResult) { try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix) { $rmContext = [ContextHelper]::GetCurrentContext(); $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "",$rmContext.AccessToken))) foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split "," foreach ($excessivePermission in $excessivePermissions) { $roleId = [int][ReleasePermissions] $excessivePermission.Replace(" ",""); $url = "https://dev.azure.com/{0}/_apis/Permissions/{1}/{2}?descriptor=Microsoft.TeamFoundation.Identity;{3}&token={4}&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $($RawDataObjForControlFix[0].PermissionSetId), $roleId,$($identity.Descriptor), $($identity.PermissionSetToken) $response = Invoke-WebRequest -Uri $url -Method Delete -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo)} #API takes some time to reflect the changes, if a new API call is made before that this permission might not be reflected even though status code is 200 if($response.StatusCode -eq 200){ Start-Sleep -seconds 1 } } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Allow" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Not set" } } else { foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split "," foreach ($excessivePermission in $excessivePermissions) { $roleId = [int][ReleasePermissions] $excessivePermission.Replace(" ",""); $body = "{ 'token': '$($identity.PermissionSetToken)', 'merge': true, 'accessControlEntries' : [{ 'descriptor' : 'Microsoft.TeamFoundation.Identity;$($identity.Descriptor)', 'allow':$($roleId), 'deny':0 }] }" | ConvertFrom-Json $url = "https://dev.azure.com/{0}/_apis/AccessControlEntries/{1}?api-version=6.0" -f $($this.OrganizationContext.OrganizationName),$RawDataObjForControlFix[0].PermissionSetId [WebRequestHelper]:: InvokePostWebRequest($url,$body) } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Not set" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Allow" } } $controlResult.AddMessage([VerificationResult]::Fixed, "Permissions for broader groups have been changed as below: "); $formattedGroupsData = $RawDataObjForControlFix | Select @{l = 'Group'; e = { $_.Group } }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions }}, @{l = 'OldPermission'; e = { $_.OldPermission }}, @{l = 'NewPermission'; e = { $_.NewPermission } } $display = ($formattedGroupsData | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`n$display"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForSvcConn ([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $projectId = $this.ResourceContext.ResourceDetails.Id $apiURL = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/{1}" -f $($this.OrganizationContext.OrganizationName), $($projectId); $serviceEndPointIdentity = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $restrictedGroups = @(); $restrictedBroaderGroups = @{} if ($this.ControlSettings.ServiceConnection.RestrictedBroaderGroupsForSvcConn ) { $restrictedBroaderGroupsForSvcConn = $this.ControlSettings.ServiceConnection.RestrictedBroaderGroupsForSvcConn; $restrictedBroaderGroupsForSvcConn.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } 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="Id"; Expression = {$_.identity.Id}},@{Name="Role"; Expression = {$_.role.displayName}},@{Name="Access"; Expression = {$_.access}}); #Checking where broader groups have user/admin permission for service connection if ($this.ControlSettings.ServiceConnection.CheckForInheritedPermissions) { $restrictedGroups = @($roleAssignments | Where-Object { $restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1] -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } else { $restrictedGroups = @($roleAssignments | Where-Object { $_.Access -eq "assigned" -and $restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1] -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $restrictedGroups.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($restrictedGroups, $true)) $restrictedGroups = @($restrictedGroups | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $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 } } $formattedGroupsDataForAutoFix = $restrictedGroups | Select @{l = 'Group'; e = { $_.Name} },@{l = 'Id'; e = { $_.Id } }, @{l = 'Role'; e = { $_.Role } } $formattedGroupsTable = ($formattedGroupsData | FT -AutoSize | Out-String -Width 512) $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)"; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $formattedGroupsDataForAutoFix; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupInheritanceSettingsForSvcConnAutomatedFix($controlResult); } $groups = $restrictedGroups | ForEach-Object { $_.Name + ': ' + $_.Role } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' } 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."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("`nNote:`nThe following groups are considered 'broad' which should not have excessive permissions: `n$($displayObj | FT | out-string -width 512)`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] CheckBroaderGroupInheritanceSettingsForSvcConnAutomatedFix([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } $body = "[" if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { $roleId = "Reader" if ($body.length -gt 1) {$body += ","} $body += @" { "userId":"$($identity.id)", "roleName":"$($roleId)", "uniqueName":"Assigned" } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Group}}, @{Name="OldRole"; Expression={$_.Role}},@{Name="NewRole"; Expression={$_.NewRole}}) } else { foreach ($identity in $RawDataObjForControlFix) { $roleId = "$($identity.role)" if ($body.length -gt 1) {$body += ","} $body += @" { "userId":"$($identity.id)", "roleName":"$($roleId)", "uniqueName":"Assigned" } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Group}}, @{Name="OldRole"; Expression={$_.OldRole}},@{Name="NewRole"; Expression={$_.Role}}) } #Patch request $body += "]" #$url = "https://feeds.dev.azure.com/{0}/{1}/_apis/packaging/Feeds/{2}/permissions?api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $url = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.project.serviceendpointrole/roleassignments/resources/{1}?api-version=5.0-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method Put -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Permission for broader groups have been changed as below: "); $display = ($RawDataObjForControlFix | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`n$display"); } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForAgentpool ([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed if ($this.ControlSettings.AgentPool.RestrictedBroaderGroupsForAgentPool) { $projectId = $this.ResourceContext.ResourceDetails.Id $apiURL = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/securityroles/scopes/distributedtask.agentqueuerole/roleassignments/resources/$($projectId)"; $agentPoolPermObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $restrictedBroaderGroups = @{} $restrictedBroaderGroupsForAgentPool = $this.ControlSettings.AgentPool.RestrictedBroaderGroupsForAgentPool; $restrictedBroaderGroupsForAgentPool.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } 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="ProjectName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Name"; Expression = {$_.identity.displayName}},@{Name="Role"; Expression = {$_.role.displayName}},@{Name="RoleId"; Expression = {$_.identity.id}},@{Name="Access"; Expression = {$_.access}}); # Checking whether the broader groups have User/Admin permissions $restrictedGroups = @(); if ($this.ControlSettings.Agentpool.CheckForInheritedPermissions) { $restrictedGroups = @($roleAssignments | Where-Object { $restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1] -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } else { $restrictedGroups = @($roleAssignments | Where-Object { $_.Access -eq "assigned" -and $restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1] -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $restrictedGroups.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($restrictedGroups, $true)) $restrictedGroups = @($restrictedGroups | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $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 | FT -AutoSize | Out-String -Width 512) $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)"; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $restrictedGroups; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupInheritanceSettingsForAgentpoolAutomatedFix($controlResult); } $groups = $restrictedGroups | ForEach-Object { $_.Name + ': ' + $_.role } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' } 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."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("Note:`nThe following groups are considered 'broad' which should not excessive permissions: `n$($displayObj | FT -AutoSize| out-string -width 512)"); } 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] CheckBroaderGroupInheritanceSettingsForAgentpoolAutomatedFix([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } $body = "[" if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { $roleId = "Reader" $userId = $identity.RoleId if ($body.length -gt 1) {$body += ","} $body += @" {"userId": "$($userId)","roleName": "$($roleId)"} "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Name}}, @{Name="OldRole"; Expression={$_.Role}},@{Name="NewRole"; Expression={$_.NewRole}}) } else { foreach ($identity in $RawDataObjForControlFix) { $roleId = "$($identity.Role)" $userId = $identity.RoleId if ($body.length -gt 1) {$body += ","} $body += @" {"userId": "$($userId)","roleName": "$($roleId)"} "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Name}}, @{Name="OldRole"; Expression={$_.OldRole}},@{Name="NewRole"; Expression={$_.Role}}) } $body += "]" #Patch request $url = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.globalagentqueuerole/roleassignments/resources/{1}?api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method Put -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Permission for broader groups have been changed as below: "); $display = ($RawDataObjForControlFix | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`n$display"); } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForVarGrp ([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed $projectId = $this.ResourceContext.ResourceDetails.Id if ($this.ControlSettings.VariableGroup.RestrictedBroaderGroupsForVariableGroup) { $restrictedBroaderGroups = @{} $restrictedBroaderGroupsForVarGrp = $this.ControlSettings.VariableGroup.RestrictedBroaderGroupsForVariableGroup; $restrictedBroaderGroupsForVarGrp.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } #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}},@{Name="RoleId"; Expression = {$_.identity.id}},@{Name="ProjectName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Access"; Expression = {$_.access}}); } # Checking whether the broader groups have User/Admin permissions if ($this.ControlSettings.VariableGroup.CheckForInheritedPermissions) { $restrictedGroups = @($roleAssignments | Where-Object { ($restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1]) -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } else { $restrictedGroups = @($roleAssignments | Where-Object { $_.Access -eq "assigned" -and ($restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1]) -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $restrictedGroups.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($restrictedGroups, $true)) $restrictedGroups = @($restrictedGroups | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $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 -Width 512) $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)"; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $restrictedGroups; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupInheritanceSettingsForVarGrpAutomatedFix($controlResult); } $groups = $restrictedGroups | ForEach-Object { $_.Name + ': ' + $_.Role } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have administrator access to variable group at a project level."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("`nNote:`nThe following groups are considered 'broad' and should not have excessive permissions: `n$( $displayObj| FT | out-string -Width 512)"); } 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; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForVarGrpAutomatedFix ([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } $body = "[" if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { $roleId = "Reader" $userId = $identity.RoleId if ($body.length -gt 1) {$body += ","} $body += "{`"userId`": `"$($userId)`",`"roleName`": `"$($roleId)`"}" } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Name}}, @{Name="OldRole"; Expression={$_.Role}},@{Name="NewRole"; Expression={$_.NewRole}}) } else { foreach ($identity in $RawDataObjForControlFix) { $roleId = "$($identity.Role)" $userId = $identity.RoleId if ($body.length -gt 1) {$body += ","} $body += "{`"userId`": `"$($userId)`",`"roleName`": `"$($roleId)`"}" } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Name}}, @{Name="OldRole"; Expression={$_.OldRole}},@{Name="NewRole"; Expression={$_.Role}}) } $body += "]" #Patch request $url = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.library/roleassignments/resources/{1}%240?api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method PUT -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Permission for broader groups have been changed as below: "); $display = ($RawDataObjForControlFix | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`n$display"); } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForSecureFile ([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed $projectId = $this.ResourceContext.ResourceDetails.Id if ($this.ControlSettings.SecureFile.RestrictedBroaderGroupsForSecureFile) { $restrictedBroaderGroups = @{} $restrictedBroaderGroupsForSecureFile = $this.ControlSettings.SecureFile.RestrictedBroaderGroupsForSecureFile; $restrictedBroaderGroupsForSecureFile.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } #Fetch Secure File RBAC $roleAssignments = @(); $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/securityroles/scopes/distributedtask.library/roleassignments/resources/$($projectId)%240" $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); if($responseObj.Count -gt 0) { $roleAssignments += ($responseObj | Select-Object -Property @{Name="Name"; Expression = {$_.identity.displayName}}, @{Name="Role"; Expression = {$_.role.displayName}}, @{Name="RoleId"; Expression = {$_.Identity.id}}, @{Name= "Access"; Expression = {$_.accessDisplayName}}); #added role id and access for UndoFix backup } # Checking whether the broader groups have User/Admin permissions if ($this.ControlSettings.SecureFile.CheckForInheritedPermissions) { $restrictedGroups = @($roleAssignments | Where-Object { ($restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1]) -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } else { $restrictedGroups = @($roleAssignments | Where-Object { $_.Access -eq "assigned" -and ($restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1]) -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $restrictedGroups.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($restrictedGroups, $true)) $restrictedGroups = @($restrictedGroups | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $restrictedGroupsCount = $restrictedGroups.Count # fail the control if restricted group found on secure file if ($restrictedGroupsCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "`nCount of broader groups that have administrator access to secure file at a project level: $($restrictedGroupsCount)"); $formattedGroupsData = $restrictedGroups | Select @{l = 'Group'; e = { $_.Name} }, @{l = 'Role'; e = { $_.Role } } $formattedGroupsTable = ($formattedGroupsData | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups: `n$formattedGroupsTable") $controlResult.SetStateData("List of groups: ", $restrictedGroups) $controlResult.AdditionalInfo += "Count of broader groups that have administrator access to secure file at a project level: $($restrictedGroupsCount)"; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $restrictedGroups; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupInheritanceSettingsForSecureFileAutomatedFix($controlResult); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have administrator access to secure file at a project level."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("`nNote: `nThe following groups are considered 'broader groups': `n$($displayObj | FT -AutoSize | out-string)"); } else { $controlResult.AddMessage([VerificationResult]::Error, "List of restricted broader groups and restricted roles for secure file is not defined in the control settings for your organization policy."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the secure file permissions at a project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForSecureFileAutomatedFix ([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } $body = "[" if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { $roleId = "Reader" $userId = $identity.RoleId if ($body.length -gt 1) {$body += ","} $body += "{`"userId`": `"$($userId)`",`"roleName`": `"$($roleId)`"}" } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Name}}, @{Name="OldRole"; Expression={$_.Role}},@{Name="NewRole"; Expression={$_.NewRole}}) } else { foreach ($identity in $RawDataObjForControlFix) { $roleId = "$($identity.Role)" $userId = $identity.RoleId if ($body.length -gt 1) {$body += ","} $body += "{`"userId`": `"$($userId)`",`"roleName`": `"$($roleId)`"}" } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.Name}}, @{Name="OldRole"; Expression={$_.OldRole}},@{Name="NewRole"; Expression={$_.Role}}) } $body += "]" #Patch request $url = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.library/roleassignments/resources/{1}%240?api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method PUT -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Permission for broader groups have been changed as below: "); $display = ($RawDataObjForControlFix | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`n$display"); } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForRepo ([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try{ $restrictedBroaderGroups = @{} $restrictedBroaderGroupsForRepo = $this.ControlSettings.Repo.RestrictedBroaderGroupsForRepo; $restrictedBroaderGroupsForRepo.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } #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 $permissionSetToken = $this.ResourceContext.ResourceDetails.id $repoSecurityNamespaceId = '2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87' $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/$($permissionSetToken)" # Checking if Inherited permissions are allowed or not. $allowPermissionBits = @(1) if ($this.ControlSettings.Repo.CheckForInheritedPermissions) { #allow permission bit for inherited permission is '3' $allowPermissionBits = @(1,3) } # 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)" $broaderGroupsList = @($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider'.identities | Where-Object { $_.subjectKind -eq 'group' -and $restrictedBroaderGroups.keys -contains $_.displayName }) if ($broaderGroupsList.Count -gt 0) { $groupsWithExcessivePermissionsList = @() $filteredBroaderGroupList = @() foreach ($broderGroup in $broaderGroupsList) { $body.dataProviderContext.properties.accountName = $broderGroup.principalName $body.dataProviderContext.properties.subjectDescriptor = $broderGroup.descriptor $identityPermissions = [WebRequestHelper]::InvokePostWebRequest($url, $body); $broaderGroupRBACObj = $identityPermissions.dataproviders."ms.vss-admin-web.security-view-permissions-data-provider".subjectPermissions $excessivePermissionList = $broaderGroupRBACObj | Where-Object { $_.displayName -in $restrictedBroaderGroups[$broderGroup.principalName.split('\')[-1]] } $excessiveEditPermissions = @() $excessivePermissionList | ForEach-Object { #effectivePermissionValue equals to 1 implies repo 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 ';') $excessivePermissionsGroupObj['Descriptor'] = $broderGroup.sid $excessivePermissionsGroupObj['PermissionSetToken'] = $permissionSetToken $excessivePermissionsGroupObj['PermissionSetId'] = $repoSecurityNamespaceId $groupsWithExcessivePermissionsList += $excessivePermissionsGroupObj $filteredBroaderGroupList += $broderGroup } } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $filteredBroaderGroupList.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($filteredBroaderGroupList, $false)) $groupsWithExcessivePermissionsList = @($groupsWithExcessivePermissionsList | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Group}) } if ($groupsWithExcessivePermissionsList.count -gt 0) { $accessList = $groupsWithExcessivePermissionsList | Select @{l = 'Group'; e = { $_.Group} }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions } } $controlResult.AddMessage([VerificationResult]::Failed,"Count of broader groups that have excessive permissions to repository at a project level: $($groupsWithExcessivePermissionsList.Count)"); $controlResult.AddMessage("Validate that the following broader groups that have excessive permissions to repositories: `n", $($accessList | FT -AutoSize | Out-String -Width 512)); $controlResult.SetStateData("List of broader groups having access to repositories: ", $accessList); $controlResult.AdditionalInfo += "Count of broader groups that have excessive permissions to repository at a project level: $($groupsWithExcessivePermissionsList.Count)"; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $groupsWithExcessivePermissionsList; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupInheritanceSettingsForRepoAutomatedFix($controlResult); } $groups = $groupsWithExcessivePermissionsList | ForEach-Object { $_.Group + ': ' + $_.ExcessivePermissions -join ',' } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' } else { $controlResult.AddMessage([VerificationResult]::Passed, "Repositories are not allowed to inherit excessive permissions for a broad group of users at project level."); } } else { $controlResult.AddMessage([VerificationResult]::Passed,"No broader groups have excessive access to repository at a project level."); } } else { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch repositories permission details at a project level."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("`nNote:`nFollowing groups are considered 'broad groups':`n$($displayObj | FT -AutoSize | Out-String -Width 512)"); $responseObj = $null; } catch { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch repositories permission details. Please verify from portal all teams/groups are granted minimum required permissions."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForRepoAutomatedFix([ControlResult] $controlResult) { try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } if (-not $this.UndoFix) { $rmContext = [ContextHelper]::GetCurrentContext(); $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "",$rmContext.AccessToken))) foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split ";" foreach ($excessivePermission in $excessivePermissions) { if ($excessivePermission.trim() -eq 'Force push (rewrite history, delete branches and tags)') { $roleId = [int][RepoPermissions] 'Forcepush' } elseif ($excessivePermission.trim() -eq "Remove others' locks") { $roleId = [int][RepoPermissions] 'Removeotherslocks' } else { $roleId = [int][RepoPermissions] $excessivePermission.Replace(" ",""); } $url = "https://dev.azure.com/{0}/_apis/Permissions/{1}/{2}?descriptor=Microsoft.TeamFoundation.Identity;{3}&token=repoV2/{4}&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $($RawDataObjForControlFix[0].PermissionSetId), $roleId,$($identity.Descriptor), $($identity.PermissionSetToken) $response = Invoke-WebRequest -Uri $url -Method Delete -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo)} #API takes some time to reflect the changes, if a new API call is made before that this permission might not be reflected even though status code is 200 if($response.StatusCode -eq 200){ Start-Sleep -seconds 1 } } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Allow" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Not set" } } else { foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split ";" foreach ($excessivePermission in $excessivePermissions) { if ($excessivePermission.trim() -eq 'Force push (rewrite history, delete branches and tags)') { $roleId = [int][RepoPermissions] 'Forcepush' } elseif ($excessivePermission.trim() -eq "Remove others' locks") { $roleId = [int][RepoPermissions] 'Removeotherslocks' } else { $roleId = [int][RepoPermissions] $excessivePermission.Replace(" ",""); } $body = "{ 'token': 'repoV2/$($identity.PermissionSetToken)', 'merge': true, 'accessControlEntries' : [{ 'descriptor' : 'Microsoft.TeamFoundation.Identity;$($identity.Descriptor)', 'allow':$($roleId), 'deny':0 }] }" | ConvertFrom-Json $url = "https://dev.azure.com/{0}/_apis/AccessControlEntries/{1}?api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $RawDataObjForControlFix[0].PermissionSetId [WebRequestHelper]:: InvokePostWebRequest($url,$body) } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Not set" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Allow" } } $controlResult.AddMessage([VerificationResult]::Fixed, "Permissions for broader groups have been changed as below: "); $formattedGroupsData = $RawDataObjForControlFix | Select @{l = 'Group'; e = { $_.Group } }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions }}, @{l = 'OldPermission'; e = { $_.OldPermission }}, @{l = 'NewPermission'; e = { $_.NewPermission } } $display = ($formattedGroupsData | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`n$display"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupInheritanceSettingsForEnv ([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed $projectId = $this.ResourceContext.ResourceDetails.Id if ([Helpers]::CheckMember($this.ControlSettings, "Environment.RestrictedBroaderGroupsForEnvironment")) { $restrictedBroaderGroups = @{} $RestrictedBroaderGroupsForEnvironment = $this.ControlSettings.Environment.RestrictedBroaderGroupsForEnvironment; $restrictedBroaderGroupsForEnvironment.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } #Fetch environment RBAC $roleAssignments = @(); $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/securityroles/scopes/distributedtask.globalenvironmentreferencerole/roleassignments/resources/$($projectId)?api-version=5.0-preview.1"; $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); if($responseObj.Count -gt 0) { $roleAssignments += ($responseObj | Select-Object -Property @{Name="Name"; Expression = {$_.identity.displayName}}, @{Name="Role"; Expression = {$_.role.displayName}}, @{Name= "Access"; Expression = {$_.accessDisplayName}}); } # Checking whether the broader groups have User/Admin permissions if ([Helpers]::CheckMember($this.ControlSettings, "Environment.CheckForInheritedPermissions") -and $this.ControlSettings.Environment.CheckForInheritedPermissions) { $restrictedGroups = @($roleAssignments | Where-Object { ($restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1]) -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } else { $restrictedGroups = @($roleAssignments | Where-Object { $_.Access -eq "assigned" -and ($restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1]) -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]]) }) } if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $restrictedGroups.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($restrictedGroups, $true)) $restrictedGroups = @($restrictedGroups | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $restrictedGroupsCount = $restrictedGroups.Count # fail the control if restricted group found on environment if ($restrictedGroupsCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "`nCount of broader groups that have administrator/user access to environment 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/user access to environment at a project level: $($restrictedGroupsCount)"; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have administrator/user access to environment at a project level."); } $displayObj = $restrictedBroaderGroups.Keys | Select-Object @{Name = "Broader Group"; Expression = {$_}}, @{Name = "Excessive Permissions"; Expression = {$restrictedBroaderGroups[$_] -join ', '}} $controlResult.AddMessage("`nNote: `nThe following groups are considered 'broader groups': `n$($displayObj | FT -AutoSize | out-string)"); } else { $controlResult.AddMessage([VerificationResult]::Error, "List of restricted broader groups and restricted roles for environment is not defined in the control settings for your organization policy."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the environment permissions at a project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBuildSvcAcctAccessOnRepository([ControlResult] $controlResult) { <# { "ControlID": "ADO_Repository_AuthZ_Dont_Grant_BuildSvcAcct_Permission", "Description": "Do not grant Build Service Accounts direct access to repositories at project level.", "Id": "", "ControlSeverity": "High", "Automated": "Yes", "MethodName": "CheckBuildSvcAcctAccessOnRepository", "Rationale": "Build service account's are default identities used as part of every build in project. Configuring these identities with excessive permissions at a project level will make every repository inheriting those permissions exposed to all build definitions in the project.", "Recommendation": "1. Go to Project Settings --> 2. Repositories --> 3. Select security --> 4. Ensure 'Excessive' permissions of 'Project Collection Build Service(organization)/[Project] Build Service' groups is not set to 'Allow'. Refer to detailed scan log (Project.LOG) for excessive permissions list.", "Tags": [ "SDL", "TCP", "Automated", "AuthZ", "MSW" ], "Enabled": true } #> $controlResult.VerificationResult = [VerificationResult]::Failed try { $excessivePermissions = $this.ControlSettings.Repo.RestrictedRolesForBuildSvcAccountsInRepo # Fetching repository RBAC using portal api's because no documented api present for this purpose. $url = 'https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1' -f $($this.OrganizationContext.OrganizationName); $refererUrl = "https://dev.azure.com/{0}/{1}/_settings/repositories?repo={2}&_a=permissionsMid" -f $($this.OrganizationContext.OrganizationName), $($this.ResourceContext.ResourceGroupName), $($this.ResourceContext.ResourceDetails.id) $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.ResourceGroupName; $inputbody.dataProviderContext.properties.permissionSetToken = "repoV2/$($this.ResourceContext.ResourceDetails.Project.id)/" $responseObj = [WebRequestHelper]::InvokePostWebRequest($url, $inputbody); $repositoryIdentities = @(); 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"))) { $repositoryIdentities = @($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider'.identities) } if($repositoryIdentities.Count -gt 0) { # fetch the groups that have access to the repo at project level $groupPermissionsBody = '{"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 $groupPermissionsBody.dataProviderContext.properties.sourcePage.url = $refererUrl $groupPermissionsBody.dataProviderContext.properties.sourcePage.routeValues.Project = $this.ResourceContext.ResourceGroupName; $groupPermissionsBody.dataProviderContext.properties.permissionSetToken = "repoV2/$($this.ResourceContext.ResourceDetails.Project.id)/" $buildServieAccountOnRepo = @() $groupsWithExcessivePermissionsList = @() foreach ($identity in $repositoryIdentities) { if ($identity.displayName -like '*Project Collection Build Service Accounts' -or $identity.displayName -like "*Project Collection Build Service ($($this.OrganizationContext.OrganizationName))" -or $identity.displayName -like "*Build Service ($($this.OrganizationContext.OrganizationName))") { $groupPermissionsBody.dataProviderContext.properties.subjectDescriptor = $identity.descriptor $responseObj = [WebRequestHelper]::InvokePostWebRequest($url, $groupPermissionsBody); $buildServiceAccountRbacObj = @($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-permissions-data-provider'.subjectPermissions) $excessivePermissionList = $buildServiceAccountRbacObj | Where-Object { $_.displayName -in $excessivePermissions } $excessivePermissionsPerGroup = @() $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 ($this.excessivePermissionBitsForRepo -contains $_.effectivePermissionValue) { $excessivePermissionsPerGroup += $_ } } } if ($excessivePermissionsPerGroup.Count -gt 0) { $groupFoundWithExcessivePermissions = $true # For PCBSA, resolve the group and check if PBS, PCBS are part of it if ($identity.displayName -like '*Project Collection Build Service Accounts') { $groupFoundWithExcessivePermissions = $false $url="https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.1-preview" -f $($this.OrganizationContext.OrganizationName); $postbody=@' {"contributionIds":["ms.vss-admin-web.org-admin-group-members-data-provider"],"dataProviderContext":{"properties":{"subjectDescriptor":"{0}","sourcePage":{"url":"https://dev.azure.com/{2}/_settings/groups?subjectDescriptor={1}","routeId":"ms.vss-admin-web.collection-admin-hub-route","routeValues":{"adminPivot":"groups","controller":"ContributedPage","action":"Execute"}}}}} '@ $postbody=$postbody.Replace("{0}",$identity.descriptor ) $postbody=$postbody.Replace("{1}",$this.OrganizationContext.OrganizationName) $rmContext = [ContextHelper]::GetCurrentContext(); $user = ""; $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $user,$rmContext.AccessToken))) try { $response = Invoke-RestMethod -Uri $url -Method Post -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)} -Body $postbody if([Helpers]::CheckMember($response.dataProviders.'ms.vss-admin-web.org-admin-group-members-data-provider', "identities")) { $buildServiceAccountIdentities = $response.dataProviders.'ms.vss-admin-web.org-admin-group-members-data-provider'.identities foreach ($eachIdentity in $buildServiceAccountIdentities) { if ($eachIdentity.displayName -like "*Project Collection Build Service ($($this.OrganizationContext.OrganizationName))" -or $eachIdentity.displayName -like "*Build Service ($($this.OrganizationContext.OrganizationName))") { $groupFoundWithExcessivePermissions = $true } } } } catch {} } if ($groupFoundWithExcessivePermissions -eq $true) { $excessivePermissionsGroupObj = @{} $excessivePermissionsGroupObj['Group'] = $identity.displayName $excessivePermissionsGroupObj['ExcessivePermissions'] = $($excessivePermissionsPerGroup.displayName -join ', ') $groupsWithExcessivePermissionsList += $excessivePermissionsGroupObj } } } } if ($groupsWithExcessivePermissionsList.count -gt 0) { #TODO: Do we need to put state object? $controlResult.AddMessage([VerificationResult]::Failed, "Count of restricted Build Service groups that have access to repository at project level: $($groupsWithExcessivePermissionsList.count)"); $formattedGroupsData = $groupsWithExcessivePermissionsList | Select @{l = 'Group'; e = { $_.Group} }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions } } $formattedBroaderGrpTable = ($formattedGroupsData | Out-String) $controlResult.AddMessage("`nList of 'Build Service' Accounts: $formattedBroaderGrpTable"); $controlResult.SetStateData("List of 'Build Service' Accounts: ", $formattedGroupsData) $controlResult.AdditionalInfo += "Count of restricted Build Service groups that have access to repository at project level: $($groupsWithExcessivePermissionsList.Count)"; } else { $controlResult.AddMessage([VerificationResult]::Passed,"Build Service accounts are not granted access to the repository at project level."); $controlResult.AdditionalInfoInCSV = "NA"; } } else{ $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch repository permission details at project level."); } $controlResult.AddMessage("`nNote:`nFollowing permissions are considered 'excessive':`n$($excessivePermissions | FT -AutoSize | Out-String -Width 512)"); } catch { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch repository permission details at project level."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckDisabledCreationOfClassicPipeline([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.PipelineSettingsObj) { $prjLevelScope = $this.PipelineSettingsObj.disableClassicPipelineCreation.enabled; $controlResult.AdditionalInfoInCSV ="NA" if($prjLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Creation of classic build and classic release pipelines is disabled."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Creation of classic build and classic release pipelines is not disabled."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch project pipeline settings."); } return $controlResult } } # SIG # Begin signature block # MIInwgYJKoZIhvcNAQcCoIInszCCJ68CAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBuu7MBJ3XZAqHW # Xy7ZIF91fEqtLvJ7mcFxorf/HUqGAqCCDXYwggX0MIID3KADAgECAhMzAAADrzBA # DkyjTQVBAAAAAAOvMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjMxMTE2MTkwOTAwWhcNMjQxMTE0MTkwOTAwWjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQDOS8s1ra6f0YGtg0OhEaQa/t3Q+q1MEHhWJhqQVuO5amYXQpy8MDPNoJYk+FWA # hePP5LxwcSge5aen+f5Q6WNPd6EDxGzotvVpNi5ve0H97S3F7C/axDfKxyNh21MG # 0W8Sb0vxi/vorcLHOL9i+t2D6yvvDzLlEefUCbQV/zGCBjXGlYJcUj6RAzXyeNAN # xSpKXAGd7Fh+ocGHPPphcD9LQTOJgG7Y7aYztHqBLJiQQ4eAgZNU4ac6+8LnEGAL # go1ydC5BJEuJQjYKbNTy959HrKSu7LO3Ws0w8jw6pYdC1IMpdTkk2puTgY2PDNzB # tLM4evG7FYer3WX+8t1UMYNTAgMBAAGjggFzMIIBbzAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQURxxxNPIEPGSO8kqz+bgCAQWGXsEw # RQYDVR0RBD4wPKQ6MDgxHjAcBgNVBAsTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEW # MBQGA1UEBRMNMjMwMDEyKzUwMTgyNjAfBgNVHSMEGDAWgBRIbmTlUAXTgqoXNzci # tW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vd3d3Lm1pY3Jvc29mdC5j # b20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3JsMGEG # CCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDovL3d3dy5taWNyb3NvZnQu # Y29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3J0 # MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIBAISxFt/zR2frTFPB45Yd # mhZpB2nNJoOoi+qlgcTlnO4QwlYN1w/vYwbDy/oFJolD5r6FMJd0RGcgEM8q9TgQ # 2OC7gQEmhweVJ7yuKJlQBH7P7Pg5RiqgV3cSonJ+OM4kFHbP3gPLiyzssSQdRuPY # 1mIWoGg9i7Y4ZC8ST7WhpSyc0pns2XsUe1XsIjaUcGu7zd7gg97eCUiLRdVklPmp # XobH9CEAWakRUGNICYN2AgjhRTC4j3KJfqMkU04R6Toyh4/Toswm1uoDcGr5laYn # TfcX3u5WnJqJLhuPe8Uj9kGAOcyo0O1mNwDa+LhFEzB6CB32+wfJMumfr6degvLT # e8x55urQLeTjimBQgS49BSUkhFN7ois3cZyNpnrMca5AZaC7pLI72vuqSsSlLalG # OcZmPHZGYJqZ0BacN274OZ80Q8B11iNokns9Od348bMb5Z4fihxaBWebl8kWEi2O # PvQImOAeq3nt7UWJBzJYLAGEpfasaA3ZQgIcEXdD+uwo6ymMzDY6UamFOfYqYWXk # ntxDGu7ngD2ugKUuccYKJJRiiz+LAUcj90BVcSHRLQop9N8zoALr/1sJuwPrVAtx # HNEgSW+AKBqIxYWM4Ev32l6agSUAezLMbq5f3d8x9qzT031jMDT+sUAoCw0M5wVt # CUQcqINPuYjbS1WgJyZIiEkBMIIHejCCBWKgAwIBAgIKYQ6Q0gAAAAAAAzANBgkq # hkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x # EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv # bjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 # IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEwOTA5WjB+MQswCQYDVQQG # EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG # A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYDVQQDEx9NaWNyb3NvZnQg # Q29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC # CgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+laUKq4BjgaBEm6f8MMHt03 # a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc6Whe0t+bU7IKLMOv2akr # rnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4Ddato88tt8zpcoRb0Rrrg # OGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+lD3v++MrWhAfTVYoonpy # 4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nkkDstrjNYxbc+/jLTswM9 # sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6A4aN91/w0FK/jJSHvMAh # dCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmdX4jiJV3TIUs+UsS1Vz8k # A/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL5zmhD+kjSbwYuER8ReTB # w3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zdsGbiwZeBe+3W7UvnSSmn # Eyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3T8HhhUSJxAlMxdSlQy90 # lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS4NaIjAsCAwEAAaOCAe0w # ggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRIbmTlUAXTgqoXNzcitW2o # ynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYD # VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBDuRQFTuHqp8cx0SOJNDBa # BgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2Ny # bC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3JsMF4GCCsG # AQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3dy5taWNyb3NvZnQuY29t # L3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3J0MIGfBgNV # HSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEFBQcCARYzaHR0cDovL3d3 # dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1hcnljcHMuaHRtMEAGCCsG # AQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkAYwB5AF8AcwB0AGEAdABl # AG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn8oalmOBUeRou09h0ZyKb # C5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7v0epo/Np22O/IjWll11l # hJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0bpdS1HXeUOeLpZMlEPXh6 # I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/KmtYSWMfCWluWpiW5IP0 # wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvyCInWH8MyGOLwxS3OW560 # STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBpmLJZiWhub6e3dMNABQam # ASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJihsMdYzaXht/a8/jyFqGa # J+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYbBL7fQccOKO7eZS/sl/ah # XJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbSoqKfenoi+kiVH6v7RyOA # 9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sLgOppO6/8MO0ETI7f33Vt # Y5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtXcVZOSEXAQsmbdlsKgEhr # /Xmfwb1tbWrJUnMTDXpQzTGCGaIwghmeAgEBMIGVMH4xCzAJBgNVBAYTAlVTMRMw # EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN # aWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNp # Z25pbmcgUENBIDIwMTECEzMAAAOvMEAOTKNNBUEAAAAAA68wDQYJYIZIAWUDBAIB # BQCgga4wGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEO # MAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIOdE+YUJ7GNMsOYt2TnsE397 # 6xvCDscOSOWJLOSjD6OPMEIGCisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8A # cwBvAGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEB # BQAEggEAOEuoH72/pqCSa9Jb4tMjiiCk4/YYbSfvunbJnR97+8IAOVqHsxIDGKQC # 8/pgE4AtvnZj7jzmOHUEoGaxCqrIkEfWiQKC7/1txP4dmZr+GL2ME2afZLr3Uozp # tmEfZH5Z+gVmlVyC+ND7yE+qSHLI7c7iGr1m+u7l8N4KEmmEpWac0Pa1i9wZoKcy # N8EiYlysPiXGOntjM9y7uKjyhLhEEGX6j4exj93dEXEaUWbNR1b8FVR5KNfDNdW7 # 74CM3KOfhtEi1CENRmLXFIpD0jHi3zeuXV4nc/1tiki2j55S4gWmCjsqnQKIIV9t # RHNBZ3gHynKiMasAEY3BLeyiLuKGN6GCFywwghcoBgorBgEEAYI3AwMBMYIXGDCC # FxQGCSqGSIb3DQEHAqCCFwUwghcBAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFZBgsq # hkiG9w0BCRABBKCCAUgEggFEMIIBQAIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFl # AwQCAQUABCDiNqjgpmgcsAZiGqpJglM7XiD/CZ0m//euvJ4/TYJBHQIGZbqfiil/ # GBMyMDI0MDIxNTA4MzIzMy44MjdaMASAAgH0oIHYpIHVMIHSMQswCQYDVQQGEwJV # UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE # ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJl # bGFuZCBPcGVyYXRpb25zIExpbWl0ZWQxJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNO # OjA4NDItNEJFNi1DMjlBMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBT # ZXJ2aWNloIIRezCCBycwggUPoAMCAQICEzMAAAHajtXJWgDREbEAAQAAAdowDQYJ # KoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x # EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv # bjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwHhcNMjMx # MDEyMTkwNjU5WhcNMjUwMTEwMTkwNjU5WjCB0jELMAkGA1UEBhMCVVMxEzARBgNV # BAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jv # c29mdCBDb3Jwb3JhdGlvbjEtMCsGA1UECxMkTWljcm9zb2Z0IElyZWxhbmQgT3Bl # cmF0aW9ucyBMaW1pdGVkMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjowODQyLTRC # RTYtQzI5QTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZTCC # AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJOQBgh2tVFR1j8jQA4NDf8b # cVrXSN080CNKPSQo7S57sCnPU0FKF47w2L6qHtwm4EnClF2cruXFp/l7PpMQg25E # 7X8xDmvxr8BBE6iASAPCfrTebuvAsZWcJYhy7prgCuBf7OidXpgsW1y8p6Vs7sD2 # aup/0uveYxeXlKtsPjMCplHkk0ba+HgLho0J68Kdji3DM2K59wHy9xrtsYK+X9er # bDGZ2mmX3765aS5Q7/ugDxMVgzyj80yJn6ULnknD9i4kUQxVhqV1dc/DF6UBeuzf # ukkMed7trzUEZMRyla7qhvwUeQlgzCQhpZjz+zsQgpXlPczvGd0iqr7lACwfVGog # 5plIzdExvt1TA8Jmef819aTKwH1IVEIwYLA6uvS8kRdA6RxvMcb//ulNjIuGceyy # kMAXEynVrLG9VvK4rfrCsGL3j30Lmidug+owrcCjQagYmrGk1hBykXilo9YB8Qyy # 5Q1KhGuH65V3zFy8a0kwbKBRs8VR4HtoPYw9z1DdcJfZBO2dhzX3yAMipCGm6Smv # mvavRsXhy805jiApDyN+s0/b7os2z8iRWGJk6M9uuT2493gFV/9JLGg5YJJCJXI+ # yxkO/OXnZJsuGt0+zWLdHS4XIXBG17oPu5KsFfRTHREloR2dI6GwaaxIyDySHYOt # vIydla7u4lfnfCjY/qKTAgMBAAGjggFJMIIBRTAdBgNVHQ4EFgQUoXyNyVE9ZhOV # izEUVwhNgL8PX0UwHwYDVR0jBBgwFoAUn6cVXQBeYl2D9OXSZacbUzUZ6XIwXwYD # VR0fBFgwVjBUoFKgUIZOaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9j # cmwvTWljcm9zb2Z0JTIwVGltZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3JsMGwG # CCsGAQUFBwEBBGAwXjBcBggrBgEFBQcwAoZQaHR0cDovL3d3dy5taWNyb3NvZnQu # Y29tL3BraW9wcy9jZXJ0cy9NaWNyb3NvZnQlMjBUaW1lLVN0YW1wJTIwUENBJTIw # MjAxMCgxKS5jcnQwDAYDVR0TAQH/BAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcD # CDAOBgNVHQ8BAf8EBAMCB4AwDQYJKoZIhvcNAQELBQADggIBALmDVdTtuI0jAEt4 # 1O2OM8CU237TGMyhrGr7FzKCEFaXxtoqk/IObQriq1caHVh2vyuQ24nz3TdOBv7r # cs/qnPjOxnXFLyZPeaWLsNuARVmUViyVYXjXYB5DwzaWZgScY8GKL7yGjyWrh78W # JUgh7rE1+5VD5h0/6rs9dBRqAzI9fhZz7spsjt8vnx50WExbBSSH7rfabHendpeq # bTmW/RfcaT+GFIsT+g2ej7wRKIq/QhnsoF8mpFNPHV1q/WK/rF/ChovkhJMDvlqt # ETWi97GolOSKamZC9bYgcPKfz28ed25WJy10VtQ9P5+C/2dOfDaz1RmeOb27Kbeg # ha0SfPcriTfORVvqPDSa3n9N7dhTY7+49I8evoad9hdZ8CfIOPftwt3xTX2RhMZJ # CVoFlabHcvfb84raFM6cz5EYk+x1aVEiXtgK6R0xn1wjMXHf0AWlSjqRkzvSnRKz # FsZwEl74VahlKVhI+Ci9RT9+6Gc0xWzJ7zQIUFE3Jiix5+7KL8ArHfBY9UFLz4sn # boJ7Qip3IADbkU4ZL0iQ8j8Ixra7aSYfToUefmct3dM69ff4Eeh2Kh9NsKiiph58 # 9Ap/xS1jESlrfjL/g/ZboaS5d9a2fA598mubDvLD5x5PP37700vm/Y+PIhmp2fTv # uS2sndeZBmyTqcUNHRNmCk+njV3nMIIHcTCCBVmgAwIBAgITMwAAABXF52ueAptJ # mQAAAAAAFTANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT # Cldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29m # dCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNh # dGUgQXV0aG9yaXR5IDIwMTAwHhcNMjEwOTMwMTgyMjI1WhcNMzAwOTMwMTgzMjI1 # WjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH # UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQD # Ex1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDCCAiIwDQYJKoZIhvcNAQEB # BQADggIPADCCAgoCggIBAOThpkzntHIhC3miy9ckeb0O1YLT/e6cBwfSqWxOdcjK # NVf2AX9sSuDivbk+F2Az/1xPx2b3lVNxWuJ+Slr+uDZnhUYjDLWNE893MsAQGOhg # fWpSg0S3po5GawcU88V29YZQ3MFEyHFcUTE3oAo4bo3t1w/YJlN8OWECesSq/XJp # rx2rrPY2vjUmZNqYO7oaezOtgFt+jBAcnVL+tuhiJdxqD89d9P6OU8/W7IVWTe/d # vI2k45GPsjksUZzpcGkNyjYtcI4xyDUoveO0hyTD4MmPfrVUj9z6BVWYbWg7mka9 # 7aSueik3rMvrg0XnRm7KMtXAhjBcTyziYrLNueKNiOSWrAFKu75xqRdbZ2De+JKR # Hh09/SDPc31BmkZ1zcRfNN0Sidb9pSB9fvzZnkXftnIv231fgLrbqn427DZM9itu # qBJR6L8FA6PRc6ZNN3SUHDSCD/AQ8rdHGO2n6Jl8P0zbr17C89XYcz1DTsEzOUyO # ArxCaC4Q6oRRRuLRvWoYWmEBc8pnol7XKHYC4jMYctenIPDC+hIK12NvDMk2ZItb # oKaDIV1fMHSRlJTYuVD5C4lh8zYGNRiER9vcG9H9stQcxWv2XFJRXRLbJbqvUAV6 # bMURHXLvjflSxIUXk8A8FdsaN8cIFRg/eKtFtvUeh17aj54WcmnGrnu3tz5q4i6t # AgMBAAGjggHdMIIB2TASBgkrBgEEAYI3FQEEBQIDAQABMCMGCSsGAQQBgjcVAgQW # BBQqp1L+ZMSavoKRPEY1Kc8Q/y8E7jAdBgNVHQ4EFgQUn6cVXQBeYl2D9OXSZacb # UzUZ6XIwXAYDVR0gBFUwUzBRBgwrBgEEAYI3TIN9AQEwQTA/BggrBgEFBQcCARYz # aHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9Eb2NzL1JlcG9zaXRvcnku # aHRtMBMGA1UdJQQMMAoGCCsGAQUFBwMIMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIA # QwBBMAsGA1UdDwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNX2 # VsuP6KJcYmjRPZSQW9fOmhjEMFYGA1UdHwRPME0wS6BJoEeGRWh0dHA6Ly9jcmwu # bWljcm9zb2Z0LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dF8yMDEw # LTA2LTIzLmNybDBaBggrBgEFBQcBAQROMEwwSgYIKwYBBQUHMAKGPmh0dHA6Ly93 # d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYt # MjMuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQCdVX38Kq3hLB9nATEkW+Geckv8qW/q # XBS2Pk5HZHixBpOXPTEztTnXwnE2P9pkbHzQdTltuw8x5MKP+2zRoZQYIu7pZmc6 # U03dmLq2HnjYNi6cqYJWAAOwBb6J6Gngugnue99qb74py27YP0h1AdkY3m2CDPVt # I1TkeFN1JFe53Z/zjj3G82jfZfakVqr3lbYoVSfQJL1AoL8ZthISEV09J+BAljis # 9/kpicO8F7BUhUKz/AyeixmJ5/ALaoHCgRlCGVJ1ijbCHcNhcy4sa3tuPywJeBTp # kbKpW99Jo3QMvOyRgNI95ko+ZjtPu4b6MhrZlvSP9pEB9s7GdP32THJvEKt1MMU0 # sHrYUP4KWN1APMdUbZ1jdEgssU5HLcEUBHG/ZPkkvnNtyo4JvbMBV0lUZNlz138e # W0QBjloZkWsNn6Qo3GcZKCS6OEuabvshVGtqRRFHqfG3rsjoiV5PndLQTHa1V1QJ # sWkBRH58oWFsc/4Ku+xBZj1p/cvBQUl+fpO+y/g75LcVv7TOPqUxUYS8vwLBgqJ7 # Fx0ViY1w/ue10CgaiQuPNtq6TPmb/wrpNPgkNWcr4A245oyZ1uEi6vAnQj0llOZ0 # dFtq0Z4+7X6gMTN9vMvpe784cETRkPHIqzqKOghif9lwY1NNje6CbaUFEMFxBmoQ # tB1VM1izoXBm8qGCAtcwggJAAgEBMIIBAKGB2KSB1TCB0jELMAkGA1UEBhMCVVMx # EzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoT # FU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEtMCsGA1UECxMkTWljcm9zb2Z0IElyZWxh # bmQgT3BlcmF0aW9ucyBMaW1pdGVkMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjow # ODQyLTRCRTYtQzI5QTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2Vy # dmljZaIjCgEBMAcGBSsOAwIaAxUAQqIfIYljHUbNoY0/wjhXRn/sSA2ggYMwgYCk # fjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH # UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQD # Ex1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQUFAAIF # AOl4OVUwIhgPMjAyNDAyMTUxNTE4NDVaGA8yMDI0MDIxNjE1MTg0NVowdzA9Bgor # BgEEAYRZCgQBMS8wLTAKAgUA6Xg5VQIBADAKAgEAAgIBcwIB/zAHAgEAAgIR0DAK # AgUA6XmK1QIBADA2BgorBgEEAYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIB # AAIDB6EgoQowCAIBAAIDAYagMA0GCSqGSIb3DQEBBQUAA4GBAKCvblXsjSLTfd3P # 2QAQZYrNwkQ+oIyY49nPcXdtze0vTtWfJp93KvNmW7EacU5+5PJvRdZwUMOgaWcg # EEzMxyxYm8/DmXBrrpJhnYLohJO7QNl5GLxhMXF+RS/JBObcDi9W3g5gSeo8C+3x # c2BhlJiOuQ3e9kXbp7yyoJsu+4rvMYIEDTCCBAkCAQEwgZMwfDELMAkGA1UEBhMC # VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV # BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRp # bWUtU3RhbXAgUENBIDIwMTACEzMAAAHajtXJWgDREbEAAQAAAdowDQYJYIZIAWUD # BAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAvBgkqhkiG9w0B # CQQxIgQgZ0DUCME+nfAyVUzpxhbw9sZawOuFMFYEg7XvlGErYcswgfoGCyqGSIb3 # DQEJEAIvMYHqMIHnMIHkMIG9BCAipaNpYsDvnqTe95Dj1C09020I5ljibrW/ndIC # Oxg9xjCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9u # MRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRp # b24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwAhMzAAAB # 2o7VyVoA0RGxAAEAAAHaMCIEIB7rxM/LsedRyf1X20Ifl3wW+XS9YShocIhKX9T9 # BZOBMA0GCSqGSIb3DQEBCwUABIICAI+wXyIC32iMxBbkNGzPL1AdB/fp0EUgTtF3 # UnlFLNc4gJ1JtS1gJrwp+qHbFgcCAv6QN/dyeOqxCC8TXrfyDInQuxmUTTvW+wbV # f70n879KkeMOEd3zLpOYnuqI505Ie1iwhEdb3GSeRsiWXLNKH4GEIzi+PrITxTTF # ObFYByr1xHc0mAE3uTPbNvDWE5ST0cxJpJOSBsz+bRWo/s2yymI9j6vOgaA7g+4M # VEFy6GBihr9izroMX9UzVNhkEkyks8637NmIbrfNhLEBvGgORuhbFJMCkmB4mKpu # OQUMslAIj3Oup2Bs0Qep0k3+gjAlJXFuIb8gfws3rd/vpmxEnSfoazP+jerxi6F1 # sOSiPLyyOlL7DComMbnjAcJxwNF/Ad+kNvVYv/e52zH1q7nsMqk8BBZixTJsF/FG # 6WPFHDR3nnvn+u9t2f1O8m1ALkcw5bVCGju8hEqssl3D7XiCx6MrMKeo6pO/mPtS # a/ak1afUA5YG+OhIyoGHRllFr6N48xb/qfQtbNYDhEa9ad0XZCCm/v+sxCEcxUpq # Uptzb+yr7CGq4Dgrl4XPdfiUjdL8nZBzLHLypzXAlN6xmIm7T1peBodOTkgKizZ0 # 27/IMYkF78a3f/ifLHQrIjMlnMYMhLTx95fG70xCOAjpuSrJqrugASUUmt+vQWfq # BgmqQhXp # SIG # End signature block |