Framework/Core/SVT/ADO/ADO.CommonSVTControls.ps1
Set-StrictMode -Version Latest class CommonSVTControls: ADOSVTBase { hidden [PSObject] $Repos; # This is used for fetching repo details #hidden [PSObject] $ProjectId; hidden [string] $checkInheritedPermissionsSecureFile = $false hidden [string] $checkInheritedPermissionsEnvironment = $false hidden [string] $checkInheritedPermissionsRepo = $false hidden [string] $checkInheritedPermissionsFeed = $false hidden [object] $repoInheritePermissions = @{}; hidden [PSObject] $excessivePermissionBitsForRepo = @(1) hidden [PSObject] $excessivePermissionsForRepoBranch = $null; hidden [string] $repoPermissionSetId = "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87"; CommonSVTControls([string] $organizationName, [SVTResource] $svtResource): Base($organizationName, $svtResource) { if ([Helpers]::CheckMember($this.ControlSettings, "SecureFile.CheckForInheritedPermissions") -and $this.ControlSettings.SecureFile.CheckForInheritedPermissions) { $this.checkInheritedPermissionsSecureFile = $true } if ([Helpers]::CheckMember($this.ControlSettings, "Environment.CheckForInheritedPermissions") -and $this.ControlSettings.Environment.CheckForInheritedPermissions) { $this.checkInheritedPermissionsEnvironment = $true } if ([Helpers]::CheckMember($this.ControlSettings, "Repo.CheckForInheritedPermissions") -and $this.ControlSettings.Build.CheckForInheritedPermissions) { #allow permission bit for inherited permission is '3' $this.checkInheritedPermissionsRepo = $true $this.excessivePermissionBitsForRepo = @(1,3) } if ([Helpers]::CheckMember($this.ControlSettings.Feed, "CheckForInheritedPermissions") -and $this.ControlSettings.Feed.CheckForInheritedPermissions) { $this.checkInheritedPermissionsFeed = $true } $this.excessivePermissionsForRepoBranch = $this.ControlSettings.Repo.ExcessivePermissionsForBranch } hidden [ControlResult] CheckInactiveRepo([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $repoDefnsObj = $this.ResourceContext.ResourceDetails; $threshold = $this.ControlSettings.Repo.RepoHistoryPeriodInDays $currentDate = Get-Date # check if repo is disabled or not if ($repoDefnsObj.isDisabled) { $controlResult.AddMessage([VerificationResult]::Failed, "Repositories does not have any commits in last $($threshold) days. "); } else { # check if repo has commits in past RepoHistoryPeriodInDays days $thresholdDate = $currentDate.AddDays(-$threshold); $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceGroupName)/_apis/git/repositories/$($repoDefnsObj.id)/commits?searchCriteria.fromDate=$($thresholdDate)&&api-version=6.0" try { $repoCommitHistoryObj = @(); $repoCommitHistoryObj += @([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($repoCommitHistoryObj[0], "count", $false)) -and ($repoCommitHistoryObj[0].count -eq 0)) { $controlResult.AddMessage([VerificationResult]::Failed, "Repositories does not have any commits in last $($threshold) days. "); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Repositories is in active state."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the history of repository [$($repoDefnsObj.name)]."); $controlResult.LogException($_) } } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch details of repository.", $_); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckRepositoryPipelinePermission([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $url = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/repository/{2}.{3}" -f $this.OrganizationContext.OrganizationName, $projectId, $projectId, $this.ResourceContext.ResourceDetails.Id; $repoPipelinePermissionObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); if (([Helpers]::CheckMember($repoPipelinePermissionObj[0], "allPipelines")) -and ($repoPipelinePermissionObj[0].allPipelines.authorized -eq $true)) { $controlResult.AddMessage([VerificationResult]::Failed, "Repository is accessible to all YAML pipelines."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Repository is not accessible to all YAML pipelines."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch repository pipeline permission."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBuildServiceAccessOnBranch([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $isControlPassing = $true $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $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.security-view-members-data-provider"],"dataProviderContext":{"properties":{"permissionSetId": "","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.routeValues.Project = $this.ResourceContext.ResourceGroupName; $inputbody.dataProviderContext.properties.permissionSetId = $this.repoPermissionSetId #first check permissions for all branches $inputbody.dataProviderContext.properties.permissionSetToken = "repoV2/$($projectId)/$($this.ResourceContext.ResourceDetails.id)/refs^heads/" #calling the common method to get permissions object on this level $autoFixStateData = @(); $excessivePermissionsListOnAllBranches = $this.CheckPermsOnBranch($url,$inputBody,$projectId,$null) if ($null -ne $excessivePermissionsListOnAllBranches -and $excessivePermissionsListOnAllBranches.count -gt 0) { $isControlPassing = $false $controlResult.AddMessage([VerificationResult]::Failed, "Build service groups have excessive permissions on 'All Branches' level of the repository."); $formattedGroupsData = $excessivePermissionsListOnAllBranches | Select @{l = 'Group'; e = { $_.Group} }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions } } $formattedBuildServiceGrpTable = ($formattedGroupsData | Out-String -Width 512) $controlResult.AddMessage("`nList of groups : `n$formattedBuildServiceGrpTable"); $groups = $formattedGroupsData | ForEach-Object { $_.Group + ': ' + $_.ExcessivePermissions } $groups = $groups -join ' ; ' $controlResult.AdditionalInfo += "List of Build service groups with excessive permission on 'All Branches' level: $($groups); "; $controlResult.AdditionalInfoInCSV+= "'All Branches' level excessive permissions: $($groups); " if ($this.ControlFixBackupRequired) { $autoFixStateData +=$excessivePermissionsListOnAllBranches; #Data object that will be required to fix the control $controlResult.BackupControlState = $autoFixStateData; } } else { $controlResult.AddMessage("Build service groups do not have excessive permissions on 'All Branches' level of the repository."); } #get all eligible branches from settings and check for each of them $individualBranches = $this.ControlSettings.Repo.BranchesToCheckForExcessivePermissions $individualBranches | foreach { $inputbody.dataProviderContext.properties.permissionSetToken = "repoV2/$($projectId)/$($this.ResourceContext.ResourceDetails.id)/refs^heads^$($_)/" $excessivePermissionsListOnBranch = $this.CheckPermsOnBranch($url,$inputBody,$projectId,$_) if ($null -ne $excessivePermissionsListOnBranch -and $excessivePermissionsListOnBranch.count -gt 0) { if($isControlPassing){ $isControlPassing = $false $controlResult.AddMessage([VerificationResult]::Failed, "Build service groups have excessive permissions on '$($_)' branch of the repository."); } else{ $controlResult.AddMessage("Build service groups have excessive permissions on '$($_)' branch of the repository."); } $formattedGroupsData = $excessivePermissionsListOnBranch | Select @{l = 'Group'; e = { $_.Group} }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions } } $formattedBuildServiceGrpTable = ($formattedGroupsData | Out-String -Width 512) $controlResult.AddMessage("`nList of groups : `n$formattedBuildServiceGrpTable"); $groups = $formattedGroupsData | ForEach-Object { $_.Group + ': ' + $_.ExcessivePermissions } $groups = $groups -join ' ; ' $controlResult.AdditionalInfo += "List of Build service groups with excessive permission on $($_) branch: $($groups); "; $controlResult.AdditionalInfoInCSV+= "$_ branch excessive permissions: $($groups); " if ($this.ControlFixBackupRequired) { $autoFixStateData +=$excessivePermissionsListOnBranch; #Data object that will be required to fix the control $controlResult.BackupControlState = $autoFixStateData; } } else { $controlResult.AddMessage("Build service groups do not have excessive permissions on '$($_)' branch of the repository."); } } #only when all the branches are passing, this controls will be passed if($isControlPassing){ $controlResult.AddMessage([VerificationResult]::Passed, "Build service groups do not have excessive permissions on either 'all branch' level or individual branches in the repository"); $controlResult.AdditionalInfoInCSV = 'NA' } $controlResult.AddMessage("`nFollowing permissions are considered 'excessive':`n$($this.excessivePermissionsForRepoBranch | FT | Out-String)"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch repository permissions."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBuildServiceAccessOnBranchAutomatedFix([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject $url = "https://dev.azure.com/{0}/_apis/AccessControlEntries/{1}?api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $this.repoPermissionSetId if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split ";" $descriptor = $identity.Descriptor 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(" ","").trim(); } #need to invoke a post request which does not accept all permissions added in the body at once #hence need to call invoke seperately for each permission $body = "{ 'token': '$($identity.PermissionSetToken)', 'merge': true, 'accessControlEntries' : [{ 'descriptor' : '$descriptor', 'allow':0, 'deny':$($roleId) }] }" | ConvertFrom-Json $result = [WebRequestHelper]:: InvokePostWebRequest($url,$body) } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Allow" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Deny" } } else { foreach ($identity in $RawDataObjForControlFix) { $descriptor = $identity.Descriptor $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(" ","").trim(); } $body = "{ 'token': '$($identity.PermissionSetToken)', 'merge': true, 'accessControlEntries' : [{ 'descriptor' : '$descriptor', 'allow':$($roleId), 'deny':0 }] }" | ConvertFrom-Json [WebRequestHelper]:: InvokePostWebRequest($url,$body) } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Deny" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Allow" } } $controlResult.AddMessage([VerificationResult]::Fixed, "Permissions for build service accounts on branches 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 } #common method to get excessive permisions for every group for a branch hidden [Object] CheckPermsOnBranch($url,$inputBody,$projectId,$branch){ $responseObj = [WebRequestHelper]::InvokePostWebRequest($url, $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 $_.displayName -like "*Project Collection Build Service Accounts" }) $broaderGroupsList+=@($responseObj[0].dataProviders.'ms.vss-admin-web.security-view-members-data-provider'.identities | Where-Object { $_.subjectKind -eq 'user' -and ($_.displayName -match "Project Collection Build Service ($($this.OrganizationContext.OrganizationName))" -or $_.displayName -like "*Build Service ($($this.OrganizationContext.OrganizationName))" )}) if ($broaderGroupsList.Count) { $groupsWithExcessivePermissionsList = @() foreach ($broaderGroup in $broaderGroupsList) { $broaderGroupInputbody = "{'contributionIds':['ms.vss-admin-web.security-view-permissions-data-provider'],'dataProviderContext':{'properties':{'subjectDescriptor':'','permissionSetId':'','permissionSetToken':'','accountName':'','sourcePage':{'routeId':'ms.vss-admin-web.project-admin-hub-route','routeValues':{'project':'PrivateProjectWithRepo','adminPivot':'repositories','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json $broaderGroupInputbody.dataProviderContext.properties.sourcePage.routeValues.Project = $this.ResourceContext.ResourceGroupName; $broaderGroupInputbody.dataProviderContext.properties.permissionSetId = $this.repoPermissionSetId if($null -eq $branch){ $broaderGroupInputbody.dataProviderContext.properties.permissionSetToken = "repoV2/$($projectId)/$($this.ResourceContext.ResourceDetails.id)/refs^heads/" } else { $broaderGroupInputbody.dataProviderContext.properties.permissionSetToken = "repoV2/$($projectId)/$($this.ResourceContext.ResourceDetails.id)/refs^heads^$($branch)/" } $broaderGroupInputbody.dataProviderContext.properties.subjectDescriptor = $broaderGroup.descriptor $broaderGroupResponseObj = @([WebRequestHelper]::InvokePostWebRequest($url, $broaderGroupInputbody)); $broaderGroupRBACObj = @($broaderGroupResponseObj[0].dataProviders.'ms.vss-admin-web.security-view-permissions-data-provider'.subjectPermissions) $excessivePermissionList = $broaderGroupRBACObj | Where-Object { $_.displayName -in $this.excessivePermissionsForRepoBranch } $excessivePermissionsPerGroup = @() $excessivePermissionList | ForEach-Object { 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 ($broaderGroup.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-members-data-provider'],'dataProviderContext':{'properties':{'subjectDescriptor':'','sourcePage':{'url':'','routeId':'ms.vss-admin-web.collection-admin-hub-route','routeValues':{'adminPivot':'groups','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json $postbody.dataProviderContext.properties.subjectDescriptor = $broaderGroup.descriptor $bodyUrl = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_settings/groups?subjectDescriptor=$($broaderGroup.descriptor)" $postbody.dataProviderContext.properties.sourcePage.url = $bodyUrl try { $response = [WebRequestHelper]::InvokePostWebRequest($url, $postbody) if([Helpers]::CheckMember($response.dataProviders.'ms.vss-admin-web.org-admin-members-data-provider', "identities")) { $buildServiceAccountIdentities = $response.dataProviders.'ms.vss-admin-web.org-admin-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'] = $broaderGroup.displayName $excessivePermissionsGroupObj['ExcessivePermissions'] = $($excessivePermissionsPerGroup.displayName -join '; ') $excessivePermissionsGroupObj['Descriptor'] = $broaderGroupResponseObj[0].dataProviders.'ms.vss-admin-web.security-view-permissions-data-provider'.identityDescriptor $excessivePermissionsGroupObj['PermissionSetToken'] = $excessivePermissionsPerGroup[0].token $groupsWithExcessivePermissionsList += $excessivePermissionsGroupObj } } } return $groupsWithExcessivePermissionsList; } else{ return $null; } } return $null; } hidden [ControlResult] CheckRepoRBACAccess([ControlResult] $controlResult) { #Control is dissabled mow <# { "ControlID": "ADO_Repository_AuthZ_Grant_Min_RBAC_Access", "Description": "All teams/groups must be granted minimum required permissions on repositories.", "Id": "Repository110", "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.ResourceGroupName)/_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.ResourceGroupName; $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.ResourceGroupName; $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] CheckInheritedPermissionsOnRepository([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $projectName = $this.ResourceContext.ResourceGroupName; #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 { # Fetch the repo permissions only if not already fetch, for all the repositories in the organization if (!$this.repoInheritePermissions.ContainsKey($projectName)) { $repoPermissionUrl = 'https://dev.azure.com/{0}/_apis/accesscontrollists/2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87?api-version=6.0' -f $this.OrganizationContext.OrganizationName; $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($repoPermissionUrl)); $respoPermissionResponseObj = $responseObj | where-object {($_.token -match "^repoV2/$projectId\/.{36}$") -and $_.inheritPermissions -eq $true} $this.repoInheritePermissions.Add($projectName, $respoPermissionResponseObj); #Clearing local variable $responseObj = $null; $respoPermissionResponseObj = $null; } if ($this.repoInheritePermissions.ContainsKey($projectName)) { # Filter the inherited permissions specific to the given project $repoPermission = @($this.repoInheritePermissions."$projectName" | where-object { $_.token -eq "repoV2/$projectId/$($this.ResourceContext.ResourceDetails.Id)" }); if($repoPermission.Count -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Inherited permission is enabled on the repository."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Inherited permission is disabled on the repository."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the permission details for repositories."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch permission details for repositories. $($_)."); $controlResult.LogException($_) } return $controlResult } hidden [PSObject] FetchRepositoriesList() { if($null -eq $this.Repos) { # fetch repositories $repoDefnURL = ("https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/$($this.ResourceContext.ResourceGroupName)/_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] CheckBroaderGroupAccessOnFeeds([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $restrictedBroaderGroups = @{} $restrictedBroaderGroupsForFeeds = $this.ControlSettings.Feed.RestrictedBroaderGroupsForFeeds if(@($restrictedBroaderGroupsForFeeds.psobject.properties).Count -gt 0){ $restrictedBroaderGroupsForFeeds.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } #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. #orgFeedURL will be used to identify if feed is org scoped or project scoped $scope = "Project" #Project property does not exist of org scoped feeds if ("Project" -notin $this.ResourceContext.ResourceDetails.PSobject.Properties.name){ $url = 'https://{0}.feeds.visualstudio.com/_apis/Packaging/Feeds/{1}/Permissions?includeIds=true&excludeInheritedPermissions=false&includeDeletedFeeds=false' -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; $controlResult.AddMessage("`n***Organization scoped feed***") $scope = "Organization" } else { $url = 'https://{0}.feeds.visualstudio.com/{1}/_apis/Packaging/Feeds/{2}/Permissions?includeIds=true&excludeInheritedPermissions=false&includeDeletedFeeds=false' -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $controlResult.AddMessage("`n***Project scoped feed***") } $feedPermissionList = @([WebRequestHelper]::InvokeGetWebRequest($url)); if ($this.checkInheritedPermissionsFeed -eq $false) { $feedPermissionList = $feedPermissionList | where-object { $_.isInheritedRole -eq $false } } $excesiveFeedsPermissions = @($feedPermissionList | Where-Object { $restrictedBroaderGroups.keys -contains $_.displayName.split('\')[-1] -and ($_.role -in $restrictedBroaderGroups[$_.displayName.split('\')[-1]])}) $feedWithBroaderGroup = @($excesiveFeedsPermissions | Select-Object -Property @{Name="FeedName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Role"; Expression = {$_.role}},@{Name="Name"; Expression = {$_.displayName}}) ; if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $feedWithBroaderGroup.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($feedWithBroaderGroup, $true)) $feedWithBroaderGroup = @($feedWithBroaderGroup | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $feedWithBroaderGroupCount = $feedWithBroaderGroup.count; if ($feedWithBroaderGroupCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Count of broader groups that have administrator/contributor/collaborator access to feed: $($feedWithBroaderGroupCount)") $display = ($feedWithBroaderGroup | FT Name, Role -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups: ", $display) $controlResult.SetStateData("List of groups: ", $feedWithBroaderGroup); $groups = $feedWithBroaderGroup | ForEach-Object { $_.Name + ': ' + $_.Role } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' if ($this.ControlFixBackupRequired) { #Data object that will be required to fix the control $excesiveFeedsPermissions | ForEach-Object{ $_ | Add-Member -MemberType NoteProperty -Name "Scope" -Value $scope } $controlResult.BackupControlState = $excesiveFeedsPermissions; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "Feed is not granted with administrator/contributor/collaborator permission to broad groups."); $controlResult.AdditionalInfoInCSV = "NA"; } $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 broader groups for feeds is not defined in control settings for your organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch feed permissions."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupAccessOnFeedsAutomatedFix([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject $scope = $RawDataObjForControlFix[0].Scope $body = "[" if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { $roleId = [int][FeedPermissions] "Reader" if ($body.length -gt 1) {$body += ","} $body += @" { "displayName": "$($($identity.displayName).Replace('\','\\'))", "identityId": "$($identity.identityId)", "role": $roleId, "identityDescriptor": "$($($identity.identityDescriptor).Replace('\','\\'))", "isInheritedRole": false } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.DisplayName}}, @{Name="OldRole"; Expression={$_.Role}},@{Name="NewRole"; Expression={$_.NewRole}}) } else { foreach ($identity in $RawDataObjForControlFix) { $roleId = [int][FeedPermissions] "$($identity.role)" if ($body.length -gt 1) {$body += ","} $body += @" { "displayName": "$($($identity.displayName).Replace('\','\\'))", "identityId": "$($identity.identityId)", "role": $roleId, "identityDescriptor": "$($($identity.identityDescriptor).Replace('\','\\'))", "isInheritedRole": false } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.DisplayName}}, @{Name="OldRole"; Expression={$_.OldRole}},@{Name="NewRole"; Expression={$_.Role}}) } #Patch request $body += "]" if ($scope -eq "Organization") { $url = "https://feeds.dev.azure.com/{0}/_apis/packaging/Feeds/{1}/permissions?api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; } else { $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; } $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method Patch -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] CheckSecureFilesPermission([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $url = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/securefile/{2}" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $secureFilePipelinePermObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); if($secureFilePipelinePermObj.Count -gt 0 -and [Helpers]::CheckMember($secureFilePipelinePermObj,"allPipelines") -and $secureFilePipelinePermObj.allPipelines.authorized -eq $true) { $controlResult.AddMessage([VerificationResult]::Failed, "Secure file is accesible to all YAML pipelines."); if ($this.ControlFixBackupRequired){ $controlResult.BackupControlState = $secureFilePipelinePermObj; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "Secure file is not accesible to all YAML pipelines."); try { $buildPipelineIds = @(); if ($secureFilePipelinePermObj.Count -gt 0 -and $secureFilePipelinePermObj.pipelines.Count -gt 0) { $buildPipelineIds = $secureFilePipelinePermObj.pipelines.id $buildDefnURL = "https://{0}.visualstudio.com/{1}/_apis/build/definitions?definitionIds={2}&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $this.ResourceContext.ResourceGroupName, ($buildPipelineIds -join ","); $buildDefnsObj = [WebRequestHelper]::InvokeGetWebRequest($buildDefnURL); if (([Helpers]::CheckMember($buildDefnsObj,"name"))) { $controlResult.AdditionalInfoInCSV = "NumYAMLPipelineWithAccess: $($buildDefnsObj.Count)" $controlResult.AdditionalInfoInCSV = "List: " + ($buildDefnsObj.Name -join ",") } } } catch { } } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch authorization details of secure file."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckSecureFilesPermissionAutomatedFix([ControlResult] $controlResult) { try{ $this.PublishCustomMessage( "`nAfter applying this fix, any YAML pipelines using this secure file will lose access. You will have to explicitly add them.", [MessageType]::Warning); $RawDataObjForControlFix = @(); $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject if (-not $this.UndoFix) { $RawDataObjForControlFix.allPipelines.authorized = $false; $RawDataObjForControlFix.allPipelines.authorizedBy = $null; $RawDataObjForControlFix.allPipelines.authorizedOn = $null; $body = $RawDataObjForControlFix | ConvertTo-Json -Depth 10; $uri = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/securefile/{2}?api-version=5.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($uri) $Result = Invoke-RestMethod -Uri $uri -Method Patch -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Secure file is not accessible to all YAML pipelines."); } else { $body = $RawDataObjForControlFix | ConvertTo-Json -Depth 10; $uri = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/securefile/{2}?api-version=5.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($uri) $Result = Invoke-RestMethod -Uri $uri -Method Patch -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Secure file is accessible to all YAML pipelines."); } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupAccessOnSecureFile([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $restrictedBroaderGroups = @{} $restrictedBroaderGroupsForSecureFile = $this.ControlSettings.SecureFile.RestrictedBroaderGroupsForSecureFile if(@($restrictedBroaderGroupsForSecureFile.psobject.properties).Count -gt 0){ $restrictedBroaderGroupsForSecureFile.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $url = 'https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.securefile/roleassignments/resources/{1}%24{2}' -f $this.OrganizationContext.OrganizationName, $projectId, $this.ResourceContext.ResourceDetails.Id; $secureFilePermissionList = @([WebRequestHelper]::InvokeGetWebRequest($url)); $roleAssignmentsToCheck = $secureFilePermissionList; if ($this.checkInheritedPermissionsSecureFile -eq $false) { $roleAssignmentsToCheck = $secureFilePermissionList | where-object { $_.access -ne "inherited" } } $excesiveSecureFilePermissions = @($roleAssignmentsToCheck | Where-Object { $restrictedBroaderGroups.keys -contains $_.identity.displayName.split('\')[-1] -and ($_.role.name -in $restrictedBroaderGroups[$_.identity.displayName.split('\')[-1]])}) $secureFileWithBroaderGroup = @($excesiveSecureFilePermissions | Select-Object -Property @{Name="SecureFileName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Role"; Expression = {$_.role.name}},@{Name="Name"; Expression = {$_.identity.displayName}}, @{Name="Id"; Expression = {$_.identity.id}}) ; if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $secureFileWithBroaderGroup.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($secureFileWithBroaderGroup, $true)) $secureFileWithBroaderGroup = @($secureFileWithBroaderGroup | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $secureFileWithBroaderGroupCount = $secureFileWithBroaderGroup.count; if ($secureFileWithBroaderGroupCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Count of broader groups that have user/administrator access to secure file: $($secureFileWithBroaderGroupCount)") $display = ($secureFileWithBroaderGroup | FT Name, Role -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups: ", $display) if ($this.ControlFixBackupRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $secureFileWithBroaderGroup; } $groups = $secureFileWithBroaderGroup | ForEach-Object { $_.Name + ': ' + $_.Role } $controlResult.AdditionalInfoInCSV = $groups -join ' ; ' } else { $controlResult.AddMessage([VerificationResult]::Passed, "Secure file is not granted with user/administrator permission to broad groups."); } $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 broader groups for secure file is not defined in control settings for your organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch secure file permissions."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupAccessOnSecureFileAutomatedFix ([ControlResult] $controlResult) { try { $RawDataObjForControlFix = @(); $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject $body = "[" if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { if ($body.length -gt 1) {$body += ","} $body += @" { "userId": "$($identity.id)", "roleName": "Reader" } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object DisplayName, @{Name="OldRole"; Expression={$_.Role}}, NewRole) } else { foreach ($identity in $RawDataObjForControlFix) { if ($body.length -gt 1) {$body += ","} $body += @" { "userId": "$($identity.id)", "roleName": "$($identity.role)" } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object DisplayName, OldRole, @{Name="NewRole"; Expression={$_.Role}}) } $body += "]" #Put request $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $url = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.securefile/roleassignments/resources/{1}%24{2}?api-version=6.1-preview.1" -f $($this.OrganizationContext.OrganizationName),$projectId,$($this.ResourceContext.ResourceDetails.Id); $rmContext = [ContextHelper]::GetCurrentContext(); $user = ""; $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $user,$rmContext.AccessToken))) $webRequestResult = Invoke-RestMethod -Uri $url -Method Put -ContentType "application/json" -Headers @{Authorization = ("Basic {0}" -f $base64AuthInfo) } -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] CheckEnviornmentAccess([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $url = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/environment/{2}" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $envPipelinePermissionObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); if (($envPipelinePermissionObj.Count -gt 0) -and ([Helpers]::CheckMember($envPipelinePermissionObj[0],"allPipelines")) -and ($envPipelinePermissionObj[0].allPipelines.authorized -eq $true)) { $controlResult.AddMessage([VerificationResult]::Failed, "Environment is accessible to all YAML pipelines."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Environment is not accessible to all YAML pipelines."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch environment's pipeline permission setting."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckPreDeploymentApprovalOnEnv([ControlResult] $controlResult){ $controlResult.VerificationResult = [VerificationResult]::Failed try{ $url = "https://dev.azure.com/{0}/{1}/_apis/pipelines/checks/configurations?resourceType=environment&resourceId={2}&api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $response = [WebRequestHelper]::InvokeGetWebRequest($url); if([Helpers]::CheckMember($response, "count") -and $response[0].count -eq 0){ $controlResult.AddMessage([VerificationResult]::Failed, "No approvals and checks have been defined for the environment."); } else{ $approvals = @($response | Where-Object{$_.type.name -eq "Approval"}) if($approvals.Count -eq 0){ $controlResult.AddMessage([VerificationResult]::Failed, "No approvals have been defined for the environment."); } else{ $controlResult.AddMessage([VerificationResult]::Passed, "Approvals have been enabled for the environment."); } } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch approvals and checks on the environment."); } return $controlResult } hidden [ControlResult] CheckPreDeploymentApproversOnEnv([ControlResult] $controlResult){ $controlResult.VerificationResult = [VerificationResult]::Failed try{ $url = "https://dev.azure.com/{0}/{1}/_apis/pipelines/checks/queryconfigurations?`$expand=settings&api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName; #using ps invoke web request instead of helper method, as post body (json array) not supported in helper method $rmContext = [ContextHelper]::GetCurrentContext(); $user = ""; $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $user,$rmContext.AccessToken))) $body = "[{'name': '$($this.ResourceContext.ResourceDetails.Name)','id': '$($this.ResourceContext.ResourceDetails.Id)','type': 'environment'}]" $response = @(Invoke-RestMethod -Uri $url -Method Post -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)} -Body $body) if([Helpers]::CheckMember($response, "count") -and $response[0].count -eq 0){ $controlResult.AddMessage([VerificationResult]::Failed, "No approvals and checks have been defined for the environment."); } else{ $approvals = @($response.value | Where-Object{$_.type.name -eq "Approval"}) if($approvals.Count -eq 0){ $controlResult.AddMessage([VerificationResult]::Failed, "No approvals have been defined for the environment."); } else{ $approvers = $approvals.settings.approvers | Select @{n='Approver name';e={$_.displayName}},@{n='Approver id';e = {$_.uniqueName}} $formattedApproversTable = ($approvers| FT -AutoSize | Out-String -width 512) $controlResult.AddMessage("`nList of approvers : `n$formattedApproversTable"); $controlResult.AdditionalInfo += "List of approvers on environment $($approvers)."; $controlResult.AddMessage([VerificationResult]::Verify, "Validate users/groups added as approver within the environment."); } } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch list of approvers on the environment."); } return $controlResult } hidden [ControlResult] CheckBroaderGroupAccessOnEnvironment([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $broaderGroups = $this.ControlSettings.Environment.RestrictedBroaderGroupsForEnvironment if(@($broaderGroups.psobject.Properties).Count -gt 0) { $restrictedBroaderGroups = @{} $broaderGroups.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } $projectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] $url = 'https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.environmentreferencerole/roleassignments/resources/{1}_{2}' -f $this.OrganizationContext.OrganizationName, $projectId, $this.ResourceContext.ResourceDetails.Id; $environmentPermissionList = @([WebRequestHelper]::InvokeGetWebRequest($url)); $roleAssignmentsToCheck = $environmentPermissionList; if ($this.checkInheritedPermissionsEnvironment -eq $false) { $roleAssignmentsToCheck = $environmentPermissionList | where-object { $_.access -ne "inherited" } } $excesiveEnvironmentPermissions = @($roleAssignmentsToCheck | Where-Object { $restrictedBroaderGroups.keys -contains $_.identity.displayName.split('\')[-1] -and ($_.role.name -in $restrictedBroaderGroups[$_.identity.displayName.split('\')[-1]])}) $environmentWithBroaderGroup = @($excesiveEnvironmentPermissions | Select-Object -Property @{Name="EnvironmentName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Role"; Expression = {$_.role.name}},@{Name="Name"; Expression = {$_.identity.displayName}}) ; if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $environmentWithBroaderGroup.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($environmentWithBroaderGroup, $true)) $environmentWithBroaderGroup = @($environmentWithBroaderGroup | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $environmentWithBroaderGroupCount = $environmentWithBroaderGroup.count; if ($environmentWithBroaderGroupCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Count of broader groups that have user/administrator access to environment: $($environmentWithBroaderGroupCount)") $display = ($environmentWithBroaderGroup | FT Name, Role -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups: ", $display) } else { $controlResult.AddMessage([VerificationResult]::Passed, "Environment is not granted with user/administrator permission to broad groups."); } $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 broader groups for environment is not defined in control settings for your organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch environment permissions."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBranchHygieneOnEnv([ControlResult] $controlResult){ try{ $url = "https://dev.azure.com/{0}/{1}/_apis/pipelines/checks/queryconfigurations?`$expand=settings&api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName; #using ps invoke web request instead of helper method, as post body (json array) not supported in helper method $rmContext = [ContextHelper]::GetCurrentContext(); $user = ""; $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $user,$rmContext.AccessToken))) $body = "[{'name': '$($this.ResourceContext.ResourceDetails.Name)','id': '$($this.ResourceContext.ResourceDetails.Id)','type': 'environment'}]" $response = @(Invoke-RestMethod -Uri $url -Method Post -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)} -Body $body) if([Helpers]::CheckMember($response, "count") -and $response[0].count -eq 0){ $controlResult.AddMessage([VerificationResult]::Failed, "No approvals and checks have been defined for the environment."); } else{ $branchControl = @() try{ $branchControl = @($response.value | Where-Object {$_.PSObject.Properties.Name -contains "settings"}) $branchControl = @($branchControl.settings | Where-Object {$_.PSObject.Properties.Name -contains "displayName" -and $_.displayName -eq "Branch Control"}) } catch{ $branchControl = @() } if($branchControl.Count -eq 0){ $controlResult.AddMessage([VerificationResult]::Failed, "Branch control has not been defined for the environment."); } else{ #response is a string of branches seperaed via comma $branches = ($branchControl.inputs.allowedBranches).Split(","); $nonPermissibleBranchesFound = $false foreach($branch in $branches){ try{ #allowed format is refs/heads/branch $branch = ($branch -split 'refs/heads/')[1] } catch{ #to catch branch names like *, refs/tags etc. $nonPermissibleBranchesFound = $true; break; } if($branch -notin $this.ControlSettings.Build.BranchesToCheckForYAMLScript){ $nonPermissibleBranchesFound = $true; break; } } if($nonPermissibleBranchesFound -eq $false){ $controlResult.AddMessage([VerificationResult]::Passed, "Deployments to the environment is allowed via standard branches only."); } else{ $controlResult.AddMessage([VerificationResult]::Verify, "Validate the branches approved for deployment to the environment."); } $branches = $branches | Select @{n='Branch name';e={$_}} $formattedBranchesTable = ($branches| FT -AutoSize | Out-String -width 512) $controlResult.AddMessage("`nList of branches : `n$formattedBranchesTable"); $controlResult.AdditionalInfo += "List of branches approved on environment $($branches)."; } } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch branch control checks on the environment."); } return $controlResult } hidden [ControlResult] CheckBuildSvcAccAccessOnFeeds([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { #orgFeedURL will be used to identify if feed is org scoped or project scoped $scope = "Project" #Project property does not exist of org scoped feeds if ("Project" -notin $this.ResourceContext.ResourceDetails.PSobject.Properties.name){ $url = 'https://{0}.feeds.visualstudio.com/_apis/Packaging/Feeds/{1}/Permissions?includeIds=true&excludeInheritedPermissions=false&includeDeletedFeeds=false' -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; $controlResult.AddMessage("`n***Organization scoped feed***") $scope = "Organization" } else { $url = 'https://{0}.feeds.visualstudio.com/{1}/_apis/Packaging/Feeds/{2}/Permissions?includeIds=true&excludeInheritedPermissions=false&includeDeletedFeeds=false' -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; $controlResult.AddMessage("`n***Project scoped feed***") } $feedPermissionList = @([WebRequestHelper]::InvokeGetWebRequest($url)); $restrictedRolesForBroaderGroupsInFeeds = $this.ControlSettings.Feed.RestrictedRolesForBuildSvcAccountsInFeed; $excessiveBuildSvcAccFeedsPerm = @($feedPermissionList | Where-Object {($restrictedRolesForBroaderGroupsInFeeds -contains $_.role) -and ` (($_.DisplayName.split('\')[-1] -like "*Project Collection Build Service ($($this.OrganizationContext.OrganizationName))") -or ` ($_.DisplayName.split('\')[-1] -like "*Build Service ($($this.OrganizationContext.OrganizationName))" ))}) $feedWithBuildSvcAcc = @($excessiveBuildSvcAccFeedsPerm | Select-Object -Property @{Name="Role"; Expression = {$_.role}},@{Name="DisplayName"; Expression = {$_.displayName}}) ; $feedWithBuildSvcAccCount = $feedWithBuildSvcAcc.count; if ($feedWithBuildSvcAccCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Count of build service accounts that have administrator/contributor/collaborator access to feed: $($feedWithBuildSvcAccCount)") $display = ($feedWithBuildSvcAcc | FT Role, DisplayName -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups: ", $display) $controlResult.SetStateData("List of groups: ", $feedWithBuildSvcAcc); $groups = $feedWithBuildSvcAcc | ForEach-Object { $_.DisplayName + ': ' + $_.Role } $controlResult.AdditionalInfoInCSV = "$($groups -join ' ; ')" #Fetching identity used to publish last 10 packages $accUsedToPublishPackage = $this.ValidateBuildSvcAccInPackage($scope, $true); if ($accUsedToPublishPackage.packagesInfo.count -gt 0) { $controlResult.AddMessage("`nList of last 10 published packages and identity used to publish: ", ($accUsedToPublishPackage.packagesInfo | FT | Out-String -Width 512)) $uniqueIdentities = $accUsedToPublishPackage.packagesInfo | select-object -Property IdentityName -Unique $controlResult.AdditionalInfo += "List of identities used to publish last 10 packages: $($uniqueIdentities.IdentityName -join ', ')"; $controlResult.AdditionalInfoInCSV += "; Last 10 publishers: $($uniqueIdentities.IdentityName -join ', ')"; } else { $controlResult.AdditionalInfo += "No package found"; $controlResult.AdditionalInfoInCSV += "; No package found"; } if ($this.ControlFixBackupRequired) { #Data object that will be required to fix the control $excessiveBuildSvcAccFeedsPerm | ForEach-Object{ $_ | Add-Member -MemberType NoteProperty -Name "Scope" -Value $scope } $controlResult.BackupControlState = $excessiveBuildSvcAccFeedsPerm; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "Feed is not granted with administrator/contributor/collaborator permission to build service accounts."); $controlResult.AdditionalInfoInCSV = "NA"; } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch feed permissions."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBuildSvcAccAccessOnFeedsAutomatedFix([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject $scope = $RawDataObjForControlFix[0].Scope $isBuildSVcAccUsedToPublishPackage = $false $body = "[" if (-not $this.UndoFix) { #If last 10 published packages are published via Build service accounts, user should provide -Force switch in the command if (-not $this.invocationContext.BoundParameters["Force"]) { $isBuildSVcAccUsedToPublishPackage = $this.ValidateBuildSvcAccInPackage($scope, $false); } if ($isBuildSVcAccUsedToPublishPackage.isBuildSvcAccUsed -eq $false) { foreach ($identity in $RawDataObjForControlFix) { $roleId = [int][FeedPermissions] "Reader" if ($body.length -gt 1) {$body += ","} $body += @" { "displayName": "$($($identity.displayName).Replace('\','\\'))", "identityId": "$($identity.identityId)", "role": $roleId, "identityDescriptor": "$($($identity.identityDescriptor).Replace('\','\\'))", "isInheritedRole": false } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.DisplayName}}, @{Name="OldRole"; Expression={$_.Role}},@{Name="NewRole"; Expression={$_.NewRole}}) } else { $this.PublishCustomMessage("Build service accounts have been used recently to publish package. Please use -Force in the command to apply fix for such feeds.`n",[MessageType]::Warning); $controlResult.AddMessage([VerificationResult]::Verify, "Build service accounts have been used recently to publish package. Please use -Force in the command to apply fix for such feeds."); return $controlResult; } } else { foreach ($identity in $RawDataObjForControlFix) { $roleId = [int][FeedPermissions] "$($identity.role)" if ($body.length -gt 1) {$body += ","} $body += @" { "displayName": "$($($identity.displayName).Replace('\','\\'))", "identityId": "$($identity.identityId)", "role": $roleId, "identityDescriptor": "$($($identity.identityDescriptor).Replace('\','\\'))", "isInheritedRole": false } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.DisplayName}}, @{Name="OldRole"; Expression={$_.OldRole}},@{Name="NewRole"; Expression={$_.Role}}) } #Patch request $body += "]" if ($scope -eq "Organization") { $url = "https://feeds.dev.azure.com/{0}/_apis/packaging/Feeds/{1}/permissions?api-version=6.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; } else { $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; } $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method Patch -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Permission for Build service accounts 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 [psobject] ValidateBuildSvcAccInPackage($scope, $detailedList) { $isBuildSvsAccUsed = $false $packagesInfo = @() try { if ($scope -eq "Organization") { #$top in this api returns data alphabetically. Also queryorder is not supported. $url = "https://feeds.dev.azure.com/{0}/_apis/packaging/Feeds/{1}/packages?api-version=6.0-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; } else { $url = "https://feeds.dev.azure.com/{0}/{1}/_apis/packaging/Feeds/{2}/packages?api-version=6.0-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id; } $packageList = @([WebRequestHelper]::InvokeGetWebRequest($url)); if ( $packageList.Count -gt 0 -and [Helpers]::CheckMember($packageList[0],"Id")) { #Get top 10 published packages $recentPackages = $packageList | Sort-Object -Property @{Expression={$_.versions.publishdate}; Descending = $true } | Select-Object -First 10 foreach ($package in $recentPackages) { if ($scope -eq "Organization") { $provenanceURL = "https://feeds.dev.azure.com/{0}/_apis/packaging/Feeds/{1}/Packages/{2}/Versions/{3}/provenance?api-version=6.0-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id, $package.id, $package.versions.id ; } else { $provenanceURL = "https://feeds.dev.azure.com/{0}/{1}/_apis/packaging/Feeds/{2}/Packages/{3}/Versions/{4}/provenance?api-version=6.0-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id, $package.id, $package.versions.id ; } $provenanceDetails = @([WebRequestHelper]::InvokeGetWebRequest($provenanceURL)); $pkgDetails = New-Object -TypeName PSObject $pkgDetails | Add-Member -NotePropertyName PackageName -NotePropertyValue $package.name $pkgDetails | Add-Member -NotePropertyName IdentityName -NotePropertyValue $provenanceDetails.provenance.data.'Common.IdentityDisplayName' $packagesInfo += $pkgDetails if (-not $detailedList) { if ($provenanceDetails.provenance.data.'Common.IdentityDisplayName' -like "*Project Collection Build Service ($($this.OrganizationContext.OrganizationName))" -or $provenanceDetails.provenance.data.'Common.IdentityDisplayName' -like "*Build Service ($($this.OrganizationContext.OrganizationName))") { $isBuildSvsAccUsed = $true break; } } } } } catch { #eat exception } $returnObj = New-Object -TypeName PSObject $returnObj | Add-Member -NotePropertyName isBuildSvcAccUsed -NotePropertyValue $isBuildSvsAccUsed $returnObj | Add-Member -NotePropertyName packagesInfo -NotePropertyValue $packagesInfo return $returnObj } hidden [ControlResult] CheckBuildSvcAcctAccessOnRepository([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed $excessivePermissions = $this.ControlSettings.Repo.RestrictedRolesForBuildSvcAccountsInRepo try { # 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)/$($this.ResourceContext.ResourceDetails.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 $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)/$($this.ResourceContext.ResourceDetails.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-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-members-data-provider', "identities")) { $buildServiceAccountIdentities = $response.dataProviders.'ms.vss-admin-web.org-admin-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 '; ') $excessivePermissionsGroupObj['Descriptor'] = $responseObj[0].dataProviders.'ms.vss-admin-web.security-view-permissions-data-provider'.identityDescriptor $excessivePermissionsGroupObj['PermissionSetToken'] = $excessivePermissionsPerGroup[0].token $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: $($groupsWithExcessivePermissionsList.count)"); $formattedGroupsData = $groupsWithExcessivePermissionsList | Select @{l = 'Group'; e = { $_.Group} }, @{l = 'ExcessivePermissions'; e = { $_.ExcessivePermissions } } $formattedBroaderGrpTable = ($formattedGroupsData | Out-String -Width 512 ) $controlResult.AddMessage("`nList of 'Build Service' Accounts: $formattedBroaderGrpTable"); $controlResult.SetStateData("List of 'Build Service' Accounts: ", $formattedGroupsData) $additionalInfoInCSV = $formattedGroupsData | ForEach-Object { $_.Group + ': ' + $_.ExcessivePermissions } $additionalInfoInCSV = $additionalInfoInCSV -join ' ; ' $controlResult.AdditionalInfo += "Count of restricted Build Service groups that have access to repository: $($groupsWithExcessivePermissionsList.Count)"; $controlResult.AdditionalInfoInCSV+= "'Repo' level excessive permissions: $($additionalInfoInCSV); " if ($this.ControlFixBackupRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $groupsWithExcessivePermissionsList; } } else { $controlResult.AddMessage([VerificationResult]::Passed,"Build Service accounts are not granted access to the repository."); $controlResult.AdditionalInfoInCSV = "NA"; } } else{ $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch repository permission details."); } $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."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBuildSvcAcctAccessOnRepositoryAutomatedFix([ControlResult] $controlResult) { try{ $RawDataObjForControlFix = @(); $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject $url = "https://dev.azure.com/{0}/_apis/AccessControlEntries/{1}?api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $this.repoPermissionSetId if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { $excessivePermissions = $identity.ExcessivePermissions -split ";" $descriptor = $identity.Descriptor 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(" ","").trim(); } #need to invoke a post request which does not accept all permissions added in the body at once #hence need to call invoke seperately for each permission $body = "{ 'token': '$($identity.PermissionSetToken)', 'merge': true, 'accessControlEntries' : [{ 'descriptor' : '$descriptor', 'allow':0, 'deny':$($roleId) }] }" | ConvertFrom-Json $result = [WebRequestHelper]:: InvokePostWebRequest($url,$body) } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Allow" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Deny" } } else { foreach ($identity in $RawDataObjForControlFix) { $descriptor = $identity.Descriptor $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(" ","").trim(); } $body = "{ 'token': '$($identity.PermissionSetToken)', 'merge': true, 'accessControlEntries' : [{ 'descriptor' : '$descriptor', 'allow':$($roleId), 'deny':0 }] }" | ConvertFrom-Json [WebRequestHelper]:: InvokePostWebRequest($url,$body) } $identity | Add-Member -NotePropertyName OldPermission -NotePropertyValue "Deny" $identity | Add-Member -NotePropertyName NewPermission -NotePropertyValue "Allow" } } $controlResult.AddMessage([VerificationResult]::Fixed, "Permissions for build service accounts 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] CheckCredentialsAndSecretsPolicyOnRepository([ControlResult] $controlResult) { # body for post request $controlResult.VerificationResult = [VerificationResult]::Failed $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": "","repositoryId": "","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.project.id)" $inputbody.dataProviderContext.properties.repositoryId = "$($this.ResourceContext.ResourceDetails.id)" $inputbody.dataProviderContext.properties.sourcePage.routeValues.project = "$($this.ResourceContext.ResourceDetails.project.Name)" $inputbody.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/{0}/{1}/_settings/repositories?repo={2}&_a=policiesMid" -f $($this.OrganizationContext.OrganizationName),$($this.ResourceContext.ResourceGroupName),$($this.ResourceContext.ResourceDetails.id) 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($null -eq $currentScopePoliciesSecrets){ $currentScopePoliciesSecrets = $policyGroups."$($credScanId)".inheritedPolicies } if ([Helpers]::CheckMember($currentScopePoliciesSecrets, "isEnabled") -and $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, "Policy to check for credentials and other secrets on the repository not found."); } } 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] CheckForInactiveFeeds([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { #orgFeedURL will be used to identify if feed is org scoped or project scoped $scope = "Project" #Project property does not exist of org scoped feeds if ("Project" -notin $this.ResourceContext.ResourceDetails.PSobject.Properties.name){ $scope = "Organization" } if ($scope -eq "Organization") { $url = "https://{0}.feeds.visualstudio.com/_apis/Packaging/Feeds/{1}/Packages?includeDescription=true&includeDeleted=false" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; } else { $url = "https://{0}.feeds.visualstudio.com/{1}/_apis/Packaging/Feeds/{2}/Packages?includeDescription=true&includeDeleted=false" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.project.id, $this.ResourceContext.ResourceDetails.Id; } $packagesList = @([WebRequestHelper]::InvokeGetWebRequest($url)); $inactiveLimit = $this.ControlSettings.FeedsAndPackages.ThreshHoldDaysForFeedsAndPackagesInactivity; if ($packagesList.Count -gt 1) { $packagesList = $packagesList |Sort-Object -Property @{Expression={$_.versions[0].publishDate}} -Descending $latestPackage = $packagesList[0] | select-object name, @{l="publishedDate"; e = {([datetime] $_.versions[0].publishDate).ToString("d MMM yyyy")}}, @{l="version";e={$_.versions.version}}, protocolType $lastPublishDate = $latestPackage.publishedDate if ((((Get-Date) - [datetime]::Parse($lastPublishDate)).Days) -gt $inactiveLimit) { if ($scope -eq "Organization") { $packageUrl = "https://{0}.feeds.visualstudio.com/_apis/Packaging/Feeds/{1}/PackageMetricsBatch?api-version=5.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.Id; } else { $packageUrl = "https://{0}.feeds.visualstudio.com/{1}/_apis/Packaging/Feeds/{2}/PackageMetricsBatch?api-version=5.1-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceDetails.project.id, $this.ResourceContext.ResourceDetails.Id; } # the below API call will fetch the additional details for the feeds such as download count and last downlad date. $body = "{'packageIds':['$($packagesList.id -join "','")']}" $rmContext = [ContextHelper]::GetCurrentContext(); $user = ""; $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $user,$rmContext.AccessToken))) $response = @(Invoke-RestMethod -Uri $packageUrl -Method Post -ContentType "application/json" -Headers @{Authorization=("Basic {0}" -f $base64AuthInfo)} -Body $body) $lastDownloadedPackage = $response.value | sort-object lastDownloaded -descending | Select-Object -first 1 $lastDownloadedPackage = $lastDownloadedPackage | select-object @{l="Name";e={$packagesList | Where-Object {$_.id -eq $lastDownloadedPackage.packageId}| Select-Object name}}, downloadCount, @{l="lastDownloaded"; e={([datetime] $_.lastDownloaded).ToString("d MMM yyyy")}} $lastDownloadedDate = $lastDownloadedPackage.lastDownloaded if ((((Get-Date) - [datetime]::Parse($lastDownloadedDate)).Days) -gt $inactiveLimit) { $controlResult.AddMessage([VerificationResult]::Failed, "Feed was inactive from last $((((Get-Date) - [datetime]::Parse($lastDownloadedDate)).Days)) days."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Feed package was last downloaded on $(([datetime] $lastDownloadedDate).ToString("d MMM yyyy"))."); } $lastDownloadedPackage = ($lastDownloadedPackage | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nLatest downloaded package in the feed: ", $lastDownloadedPackage); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Feed package was last published on $(([datetime] $lastPublishDate).ToString("d MMM yyyy"))."); } $latestPackage = ($latestPackage | FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nLatest published package in the feed: ", $latestPackage); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Feed does not contain any packages."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch feed activity details."); $controlResult.LogException($_) } return $controlResult } } # SIG # Begin signature block # MIIjoQYJKoZIhvcNAQcCoIIjkjCCI44CAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCtNtXRM7y7lCG0 # 4l5jTsc5HNXDGQmfx/8xgjA3BEDp5aCCDYEwggX/MIID56ADAgECAhMzAAACUosz # qviV8znbAAAAAAJSMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjEwOTAyMTgzMjU5WhcNMjIwOTAxMTgzMjU5WjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQDQ5M+Ps/X7BNuv5B/0I6uoDwj0NJOo1KrVQqO7ggRXccklyTrWL4xMShjIou2I # sbYnF67wXzVAq5Om4oe+LfzSDOzjcb6ms00gBo0OQaqwQ1BijyJ7NvDf80I1fW9O # L76Kt0Wpc2zrGhzcHdb7upPrvxvSNNUvxK3sgw7YTt31410vpEp8yfBEl/hd8ZzA # v47DCgJ5j1zm295s1RVZHNp6MoiQFVOECm4AwK2l28i+YER1JO4IplTH44uvzX9o # RnJHaMvWzZEpozPy4jNO2DDqbcNs4zh7AWMhE1PWFVA+CHI/En5nASvCvLmuR/t8 # q4bc8XR8QIZJQSp+2U6m2ldNAgMBAAGjggF+MIIBejAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUNZJaEUGL2Guwt7ZOAu4efEYXedEw # UAYDVR0RBEkwR6RFMEMxKTAnBgNVBAsTIE1pY3Jvc29mdCBPcGVyYXRpb25zIFB1 # ZXJ0byBSaWNvMRYwFAYDVQQFEw0yMzAwMTIrNDY3NTk3MB8GA1UdIwQYMBaAFEhu # ZOVQBdOCqhc3NyK1bajKdQKVMFQGA1UdHwRNMEswSaBHoEWGQ2h0dHA6Ly93d3cu # bWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY0NvZFNpZ1BDQTIwMTFfMjAxMS0w # Ny0wOC5jcmwwYQYIKwYBBQUHAQEEVTBTMFEGCCsGAQUFBzAChkVodHRwOi8vd3d3 # Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY0NvZFNpZ1BDQTIwMTFfMjAx # MS0wNy0wOC5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEAFkk3 # uSxkTEBh1NtAl7BivIEsAWdgX1qZ+EdZMYbQKasY6IhSLXRMxF1B3OKdR9K/kccp # kvNcGl8D7YyYS4mhCUMBR+VLrg3f8PUj38A9V5aiY2/Jok7WZFOAmjPRNNGnyeg7 # l0lTiThFqE+2aOs6+heegqAdelGgNJKRHLWRuhGKuLIw5lkgx9Ky+QvZrn/Ddi8u # TIgWKp+MGG8xY6PBvvjgt9jQShlnPrZ3UY8Bvwy6rynhXBaV0V0TTL0gEx7eh/K1 # o8Miaru6s/7FyqOLeUS4vTHh9TgBL5DtxCYurXbSBVtL1Fj44+Od/6cmC9mmvrti # yG709Y3Rd3YdJj2f3GJq7Y7KdWq0QYhatKhBeg4fxjhg0yut2g6aM1mxjNPrE48z # 6HWCNGu9gMK5ZudldRw4a45Z06Aoktof0CqOyTErvq0YjoE4Xpa0+87T/PVUXNqf # 7Y+qSU7+9LtLQuMYR4w3cSPjuNusvLf9gBnch5RqM7kaDtYWDgLyB42EfsxeMqwK # WwA+TVi0HrWRqfSx2olbE56hJcEkMjOSKz3sRuupFCX3UroyYf52L+2iVTrda8XW # esPG62Mnn3T8AuLfzeJFuAbfOSERx7IFZO92UPoXE1uEjL5skl1yTZB3MubgOA4F # 8KoRNhviFAEST+nG8c8uIsbZeb08SeYQMqjVEmkwggd6MIIFYqADAgECAgphDpDS # AAAAAAADMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMK # V2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0 # IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0 # ZSBBdXRob3JpdHkgMjAxMTAeFw0xMTA3MDgyMDU5MDlaFw0yNjA3MDgyMTA5MDla # MH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS # ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMT # H01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTEwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQCr8PpyEBwurdhuqoIQTTS68rZYIZ9CGypr6VpQqrgG # OBoESbp/wwwe3TdrxhLYC/A4wpkGsMg51QEUMULTiQ15ZId+lGAkbK+eSZzpaF7S # 35tTsgosw6/ZqSuuegmv15ZZymAaBelmdugyUiYSL+erCFDPs0S3XdjELgN1q2jz # y23zOlyhFvRGuuA4ZKxuZDV4pqBjDy3TQJP4494HDdVceaVJKecNvqATd76UPe/7 # 4ytaEB9NViiienLgEjq3SV7Y7e1DkYPZe7J7hhvZPrGMXeiJT4Qa8qEvWeSQOy2u # M1jFtz7+MtOzAz2xsq+SOH7SnYAs9U5WkSE1JcM5bmR/U7qcD60ZI4TL9LoDho33 # X/DQUr+MlIe8wCF0JV8YKLbMJyg4JZg5SjbPfLGSrhwjp6lm7GEfauEoSZ1fiOIl # XdMhSz5SxLVXPyQD8NF6Wy/VI+NwXQ9RRnez+ADhvKwCgl/bwBWzvRvUVUvnOaEP # 6SNJvBi4RHxF5MHDcnrgcuck379GmcXvwhxX24ON7E1JMKerjt/sW5+v/N2wZuLB # l4F77dbtS+dJKacTKKanfWeA5opieF+yL4TXV5xcv3coKPHtbcMojyyPQDdPweGF # RInECUzF1KVDL3SV9274eCBYLBNdYJWaPk8zhNqwiBfenk70lrC8RqBsmNLg1oiM # CwIDAQABo4IB7TCCAekwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFEhuZOVQ # BdOCqhc3NyK1bajKdQKVMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1Ud # DwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFHItOgIxkEO5FAVO # 4eqnxzHRI4k0MFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwubWljcm9zb2Z0 # LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcmwwXgYIKwYBBQUHAQEEUjBQME4GCCsGAQUFBzAChkJodHRwOi8vd3d3Lm1p # Y3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcnQwgZ8GA1UdIASBlzCBlDCBkQYJKwYBBAGCNy4DMIGDMD8GCCsGAQUFBwIB # FjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2RvY3MvcHJpbWFyeWNw # cy5odG0wQAYIKwYBBQUHAgIwNB4yIB0ATABlAGcAYQBsAF8AcABvAGwAaQBjAHkA # XwBzAHQAYQB0AGUAbQBlAG4AdAAuIB0wDQYJKoZIhvcNAQELBQADggIBAGfyhqWY # 4FR5Gi7T2HRnIpsLlhHhY5KZQpZ90nkMkMFlXy4sPvjDctFtg/6+P+gKyju/R6mj # 82nbY78iNaWXXWWEkH2LRlBV2AySfNIaSxzzPEKLUtCw/WvjPgcuKZvmPRul1LUd # d5Q54ulkyUQ9eHoj8xN9ppB0g430yyYCRirCihC7pKkFDJvtaPpoLpWgKj8qa1hJ # Yx8JaW5amJbkg/TAj/NGK978O9C9Ne9uJa7lryft0N3zDq+ZKJeYTQ49C/IIidYf # wzIY4vDFLc5bnrRJOQrGCsLGra7lstnbFYhRRVg4MnEnGn+x9Cf43iw6IGmYslmJ # aG5vp7d0w0AFBqYBKig+gj8TTWYLwLNN9eGPfxxvFX1Fp3blQCplo8NdUmKGwx1j # NpeG39rz+PIWoZon4c2ll9DuXWNB41sHnIc+BncG0QaxdR8UvmFhtfDcxhsEvt9B # xw4o7t5lL+yX9qFcltgA1qFGvVnzl6UJS0gQmYAf0AApxbGbpT9Fdx41xtKiop96 # eiL6SJUfq/tHI4D1nvi/a7dLl+LrdXga7Oo3mXkYS//WsyNodeav+vyL6wuA6mk7 # r/ww7QRMjt/fdW1jkT3RnVZOT7+AVyKheBEyIXrvQQqxP/uozKRdwaGIm1dxVk5I # RcBCyZt2WwqASGv9eZ/BvW1taslScxMNelDNMYIVdjCCFXICAQEwgZUwfjELMAkG # A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx # HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9z # b2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAxMQITMwAAAlKLM6r4lfM52wAAAAACUjAN # BglghkgBZQMEAgEFAKCBsDAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgor # BgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQg7hqFOyuO # +ITqa3WbLcXTwY7oibGkr+d26KIRKoblGXkwRAYKKwYBBAGCNwIBDDE2MDSgFIAS # AE0AaQBjAHIAbwBzAG8AZgB0oRyAGmh0dHBzOi8vd3d3Lm1pY3Jvc29mdC5jb20g # MA0GCSqGSIb3DQEBAQUABIIBAKd2N9GPyfxY3aBM1tDCHF6/zchGW1exTBv/Sm1Y # fwpjKbg6m5ue2rNhmVPqkcnXmW21WS52yDpydipHureZjTVUkQgLNTc6m/m00w+U # VmnwjS9h5fb3pzYYmynSBGeYTjMvBWZrpawd0Puirlb6+45TQ9gd2Vk3Q++p0r3Z # nu+qPqJihgeVHnP3l58c1+n06SsrEvlygP88WBHtSVUnLJ1tr1/ZhTs/VAg1tdN9 # qyBPFIDPuWE2qXYuLrcJAj7N60cFhthV+a4+GTlETyWSeo5X7lJLMkbCIT/7lGBN # Is2lLq7jJwsc+LBvL/PUh8f+J4uCaeyCCdMyc7zXfcOoBPmhghL+MIIS+gYKKwYB # BAGCNwMDATGCEuowghLmBgkqhkiG9w0BBwKgghLXMIIS0wIBAzEPMA0GCWCGSAFl # AwQCAQUAMIIBWQYLKoZIhvcNAQkQAQSgggFIBIIBRDCCAUACAQEGCisGAQQBhFkK # AwEwMTANBglghkgBZQMEAgEFAAQgfigEKNbzzQ68uxad+3RWfpI/eOnB0p6030RY # tryYBMQCBmGDC9+WYRgTMjAyMTExMTUxMDIwMjAuMzM2WjAEgAIB9KCB2KSB1TCB # 0jELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl # ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEtMCsGA1UECxMk # TWljcm9zb2Z0IElyZWxhbmQgT3BlcmF0aW9ucyBMaW1pdGVkMSYwJAYDVQQLEx1U # aGFsZXMgVFNTIEVTTjpEMDgyLTRCRkQtRUVCQTElMCMGA1UEAxMcTWljcm9zb2Z0 # IFRpbWUtU3RhbXAgU2VydmljZaCCDk0wggT5MIID4aADAgECAhMzAAABQa9/Updc # 8txFAAAAAAFBMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQI # EwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3Nv # ZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBD # QSAyMDEwMB4XDTIwMTAxNTE3MjgyN1oXDTIyMDExMjE3MjgyN1owgdIxCzAJBgNV # BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w # HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xLTArBgNVBAsTJE1pY3Jvc29m # dCBJcmVsYW5kIE9wZXJhdGlvbnMgTGltaXRlZDEmMCQGA1UECxMdVGhhbGVzIFRT # UyBFU046RDA4Mi00QkZELUVFQkExJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0 # YW1wIFNlcnZpY2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDyKsuo # vbx1qX48lSZQk7C9if+w4ITNDNnKP3hdsU+1GEOE+NqC+8o6/UaAyg65e+Skze4z # kTru/I4I/GqFMDOrsCTfOQRdcG/smgzxebTHlRycCSlSISjR7JxGXpudggHcqVlb # e7Pglza/YVXQoIaQuu+p8o/xX+LaFXI3zng56NanHzAZRrMzgJhRWjByAQNzvo5j # 5drhbbsMJkmbZ/2iXgGNIv5vYv9Pyf8sQaSdpTHBJBM3UtAK364EMIBJ3pVBHkIs # slW3owEvRbU2VpbEQAnCUh3IGdLWKEu9GDWNz8pRl7SxEV0Pd+EquM9pzeGjLTTs # W+voajRYnimvasl1AgMBAAGjggEbMIIBFzAdBgNVHQ4EFgQULSg/LG4ukP1FcGv1 # zw07QQUSxsQwHwYDVR0jBBgwFoAU1WM6XIoxkPNDe3xGG8UzaFqFbVUwVgYDVR0f # BE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJv # ZHVjdHMvTWljVGltU3RhUENBXzIwMTAtMDctMDEuY3JsMFoGCCsGAQUFBwEBBE4w # TDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0 # cy9NaWNUaW1TdGFQQ0FfMjAxMC0wNy0wMS5jcnQwDAYDVR0TAQH/BAIwADATBgNV # HSUEDDAKBggrBgEFBQcDCDANBgkqhkiG9w0BAQsFAAOCAQEAUnrfIn3YKGoKCl7E # PjyiS+Ia4LiKQ2y+bCUJJ63WwWm8kTMskHxxMXjPipBPPwCxC1x7DqYBWZAJgO71 # /l57XXFePUvAdixkadKz859G6XVUqZjnU5Cmq8rk78K3h+zQhuXOjx7I4snKBOT3 # tpdUDhTOrwZuUvstDleLABjjjow4Auc1cWj8qr22/OKnBjVhxGf+eUxD5v3Y6Isu # YdUyLbABQ5GIoW9oB9haOFB37aocsyx2k2W7AFtZ7sUXkAGwOjQdE4xbIKDF0tDe # +qvcMxIfdlYqKKsWy9YmZyF9k8ZE5vCqzO9Jc8WY8Fn2j56v7hSgtX7nQZaedCTL # sdWAuTCCBnEwggRZoAMCAQICCmEJgSoAAAAAAAIwDQYJKoZIhvcNAQELBQAwgYgx # CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRt # b25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1p # Y3Jvc29mdCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTEwMDcw # MTIxMzY1NVoXDTI1MDcwMTIxNDY1NVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgT # Cldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29m # dCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENB # IDIwMTAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCpHQ28dxGKOiDs # /BOX9fp/aZRrdFQQ1aUKAIKF++18aEssX8XD5WHCdrc+Zitb8BVTJwQxH0EbGpUd # zgkTjnxhMFmxMEQP8WCIhFRDDNdNuDgIs0Ldk6zWczBXJoKjRQ3Q6vVHgc2/JGAy # WGBG8lhHhjKEHnRhZ5FfgVSxz5NMksHEpl3RYRNuKMYa+YaAu99h/EbBJx0kZxJy # GiGKr0tkiVBisV39dx898Fd1rL2KQk1AUdEPnAY+Z3/1ZsADlkR+79BL/W7lmsqx # qPJ6Kgox8NpOBpG2iAg16HgcsOmZzTznL0S6p/TcZL2kAcEgCZN4zfy8wMlEXV4W # nAEFTyJNAgMBAAGjggHmMIIB4jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU # 1WM6XIoxkPNDe3xGG8UzaFqFbVUwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEw # CwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZWy4/o # olxiaNE9lJBb186aGMQwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNy # b3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYt # MjMuY3JsMFoGCCsGAQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5t # aWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5j # cnQwgaAGA1UdIAEB/wSBlTCBkjCBjwYJKwYBBAGCNy4DMIGBMD0GCCsGAQUFBwIB # FjFodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vUEtJL2RvY3MvQ1BTL2RlZmF1bHQu # aHRtMEAGCCsGAQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAFAAbwBsAGkAYwB5AF8A # UwB0AGEAdABlAG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQAH5ohRDeLG # 4Jg/gXEDPZ2joSFvs+umzPUxvs8F4qn++ldtGTCzwsVmyWrf9efweL3HqJ4l4/m8 # 7WtUVwgrUYJEEvu5U4zM9GASinbMQEBBm9xcF/9c+V4XNZgkVkt070IQyK+/f8Z/ # 8jd9Wj8c8pl5SpFSAK84Dxf1L3mBZdmptWvkx872ynoAb0swRCQiPM/tA6WWj1kp # vLb9BOFwnzJKJ/1Vry/+tuWOM7tiX5rbV0Dp8c6ZZpCM/2pif93FSguRJuI57BlK # cWOdeyFtw5yjojz6f32WapB4pm3S4Zz5Hfw42JT0xqUKloakvZ4argRCg7i1gJsi # OCC1JeVk7Pf0v35jWSUPei45V3aicaoGig+JFrphpxHLmtgOR5qAxdDNp9DvfYPw # 4TtxCd9ddJgiCGHasFAeb73x4QDf5zEHpJM692VHeOj4qEir995yfmFrb3epgcun # Caw5u+zGy9iCtHLNHfS4hQEegPsbiSpUObJb2sgNVZl6h3M7COaYLeqN4DMuEin1 # wC9UJyH3yKxO2ii4sanblrKnQqLJzxlBTeCG+SqaoxFmMNO7dDJL32N79ZmKLxvH # Ia9Zta7cRDyXUHHXodLFVeNp3lfB0d4wwP3M5k37Db9dT+mdHhk4L7zPWAUu7w2g # UDXa7wknHNWzfjUeCLraNtvTX4/edIhJEqGCAtcwggJAAgEBMIIBAKGB2KSB1TCB # 0jELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl # ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEtMCsGA1UECxMk # TWljcm9zb2Z0IElyZWxhbmQgT3BlcmF0aW9ucyBMaW1pdGVkMSYwJAYDVQQLEx1U # aGFsZXMgVFNTIEVTTjpEMDgyLTRCRkQtRUVCQTElMCMGA1UEAxMcTWljcm9zb2Z0 # IFRpbWUtU3RhbXAgU2VydmljZaIjCgEBMAcGBSsOAwIaAxUAquW/KbUKq4ihByvO # dwu+QPValQOggYMwgYCkfjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAN # BgkqhkiG9w0BAQUFAAIFAOU8srUwIhgPMjAyMTExMTUxODE5MzNaGA8yMDIxMTEx # NjE4MTkzM1owdzA9BgorBgEEAYRZCgQBMS8wLTAKAgUA5TyytQIBADAKAgEAAgIU # ZgIB/zAHAgEAAgIRQjAKAgUA5T4ENQIBADA2BgorBgEEAYRZCgQCMSgwJjAMBgor # BgEEAYRZCgMCoAowCAIBAAIDB6EgoQowCAIBAAIDAYagMA0GCSqGSIb3DQEBBQUA # A4GBAIb1BT2+FSmooGdDEvn0dgX/H5C5cQNrYkajWGD9gvfdtF4N8uhUe5+No2+v # /o0vRzJkdXcFGvZkdSZabHxQoTWHv+78ZaX7faHxdCDCD/Wx0qtdd+cvKxZ1Nd+m # i7IPISAeT9TlYgN4Gsky1QjZlpbkveaqf9w8z5RQVEm3nReeMYIDDTCCAwkCAQEw # gZMwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT # B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE # AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAFBr39Sl1zy3EUA # AAAAAUEwDQYJYIZIAWUDBAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0B # CRABBDAvBgkqhkiG9w0BCQQxIgQgd23FIvu5aHWaLoT1j+K4bmvGv0+b1pIiU80/ # g3zNyx0wgfoGCyqGSIb3DQEJEAIvMYHqMIHnMIHkMIG9BCBRPwE8jOpzdJ5wdE8s # oG1bS846dP7vyFpaj5dzFV6t3jCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w # IFBDQSAyMDEwAhMzAAABQa9/Updc8txFAAAAAAFBMCIEIAQqMBtDS/yIorcM5sRn # cM0YjPEN90+Km7g/slMJ8BxmMA0GCSqGSIb3DQEBCwUABIIBADNyJTZRkV1dF2Bf # nBRhS+VKFdgID6ckoQq2I9vc569EmWG98Y8c4ENPZXePwJ6l7gBFgQkUpWzTcSDP # 1e6FH0CMhE13pP5MTH3M7u1+M7WjFgzHrFixG3rBKw3Usn6MCKi0xSDj+Tha/1Tf # bNeUYVnoOJRIQhmJQE+s7njGqZ72qZwARQvI58rXxyweiVgp1IyA9IAZO4dR9cy9 # ErNWGYhjXqVULh27YEyy0nREtMZPkWlS5V7WNTGtIQpO7tFllYxaH6No+FKKMAhN # QHIDCFGdL/cPd3CZ8CsSX5a3DBxUp6vz00uj5TmyWU0kawEBmdrZj4neDRHqTp76 # zKTZQ1k= # SIG # End signature block |