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 [object] $repoInheritePermissions = @{}; 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 } } 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 pipelines."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Repository is not accessible to all pipelines."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch repository pipeline permission."); $controlResult.LogException($_) } return $controlResult } 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 { $RestrictedBroaderGroupsForFeeds = $null; if ([Helpers]::CheckMember($this.ControlSettings, "Feed.RestrictedBroaderGroupsForFeeds")) { $restrictedBroaderGroupsForFeeds = $this.ControlSettings.Feed.RestrictedBroaderGroupsForFeeds $restrictedRolesForBroaderGroupsInFeeds = $this.ControlSettings.Feed.RestrictedRolesForBroaderGroupsInFeeds; #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 $orgFeedURL = 'https://feeds.dev.azure.com/{0}/_apis/packaging/feeds*' -f $this.OrganizationContext.OrganizationName $scope = "Project" if ($this.ResourceContext.ResourceDetails.url -match $orgFeedURL){ $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)); $excesiveFeedsPermissions = @($feedPermissionList | Where-Object {($restrictedRolesForBroaderGroupsInFeeds -contains $_.role) -and ($restrictedBroaderGroupsForFeeds -contains $_.DisplayName.split('\')[-1])}) $feedWithBroaderGroup = @($excesiveFeedsPermissions | Select-Object -Property @{Name="FeedName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Role"; Expression = {$_.role}},@{Name="DisplayName"; Expression = {$_.displayName}}) ; $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 FeedName, Role, DisplayName -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups: ", $display) $controlResult.SetStateData("List of groups: ", $feedWithBroaderGroup); 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.AddMessage("`nNote: `nThe following groups are considered 'broader groups': `n$($restrictedBroaderGroupsForFeeds | FT | 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/build/authorizedresources?type=securefile&id={2}&api-version=6.0-preview.1" -f $this.OrganizationContext.OrganizationName, $this.ResourceContext.ResourceGroupName, $this.ResourceContext.ResourceDetails.Id $secureFileObj = @([WebRequestHelper]::InvokeGetWebRequest($url)); if(($secureFileObj.Count -gt 0) -and [Helpers]::CheckMember($secureFileObj[0], "authorized") -and $secureFileObj[0].authorized -eq $true) { $controlResult.AddMessage([VerificationResult]::Failed, "Secure file is accesible to all pipelines."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Secure file is not accesible to all pipelines."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch authorization details of secure file."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupAccessOnSecureFile([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $restrictedBroaderGroupsForSecureFile = $null; if ([Helpers]::CheckMember($this.ControlSettings, "SecureFile.RestrictedBroaderGroupsForSecureFile")) { $restrictedBroaderGroupsForSecureFile = $this.ControlSettings.SecureFile.RestrictedBroaderGroupsForSecureFile $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 {$_.role.name -eq "administrator" -or $_.role.name -eq "user"}) | Select-Object -Property @{Name="SecureFileName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Role"; Expression = {$_.role.name}},@{Name="DisplayName"; Expression = {$_.identity.displayName}}) ; $secureFileWithBroaderGroup = @($excesiveSecureFilePermissions | Where-Object { $restrictedBroaderGroupsForSecureFile -contains $_.DisplayName.split('\')[-1] }) $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 SecureFileName, Role, DisplayName -AutoSize | Out-String -Width 512) $controlResult.AddMessage("`nList of groups: ", $display) } else { $controlResult.AddMessage([VerificationResult]::Passed, "Secure file is not granted with user/administrator permission to broad groups."); } $controlResult.AddMessage("`nNote: `nThe following groups are considered 'broader groups': `n$($restrictedBroaderGroupsForSecureFile | FT | 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] 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 pipelines."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Environment is not accessible to all pipelines."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch environment's pipeline permission setting."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckBroaderGroupAccessOnEnvironment([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { $restrictedBroaderGroupsForEnvironment = $null; if ([Helpers]::CheckMember($this.ControlSettings, "Environment.RestrictedBroaderGroupsForEnvironment")) { $restrictedBroaderGroupsForEnvironment = $this.ControlSettings.Environment.RestrictedBroaderGroupsForEnvironment $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 {$_.role.name -eq "administrator" -or $_.role.name -eq "user"}) | Select-Object -Property @{Name="EnvironmentName"; Expression = {$this.ResourceContext.ResourceName}},@{Name="Role"; Expression = {$_.role.name}},@{Name="DisplayName"; Expression = {$_.identity.displayName}}) ; $environmentWithBroaderGroup = @($excesiveEnvironmentPermissions | Where-Object { $restrictedBroaderGroupsForEnvironment -contains $_.DisplayName.split('\')[-1] }) $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 EnvironmentName, Role, DisplayName -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."); } $controlResult.AddMessage("`nNote: `nThe following groups are considered 'broader groups': `n$($restrictedBroaderGroupsForEnvironment | FT | 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] CheckBuildSvcAccAccessOnFeeds([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { #orgFeedURL will be used to identify if feed is org scoped or project scoped $orgFeedURL = 'https://feeds.dev.azure.com/{0}/_apis/packaging/feeds*' -f $this.OrganizationContext.OrganizationName $scope = "Project" if ($this.ResourceContext.ResourceDetails.url -match $orgFeedURL){ $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.RestrictedRolesForBroaderGroupsInFeeds; $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); 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."); } } 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); } if ($isBuildSVcAccUsedToPublishPackage -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 [boolean] ValidateBuildSvcAccInPackage($scope) { $isBuildSvsAccUsed = $false 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)); #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)); 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 } return $isBuildSvsAccUsed } hidden [ControlResult] CheckBuildSvcAcctAccessOnRepository([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed 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) { $buildServieAccountOnRepo = @() foreach ($identity in $repositoryIdentities) { if ($identity.displayName -like '*Project Collection Build Service Accounts' -or $identity.displayName -like "*Build Service ($($this.OrganizationContext.OrganizationName))") { $buildServieAccountOnRepo += $identity.displayName; } } $restrictedBuildSVCAcctCount = $buildServieAccountOnRepo.Count; if($restrictedBuildSVCAcctCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Count of restricted Build Service groups that have access to repository: $($restrictedBuildSVCAcctCount)") $controlResult.AddMessage("`nList of 'Build Service' Accounts: ", $($buildServieAccountOnRepo | FT | Out-String)) $controlResult.SetStateData("List of 'Build Service' Accounts: ", $buildServieAccountOnRepo) $controlResult.AdditionalInfo += "Count of restricted Build Service groups that have access to service connection: $($restrictedBuildSVCAcctCount)"; } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Build Service accounts are not granted access to the repository."); } } else{ $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch repository permission details."); } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch repository permission details."); $controlResult.LogException($_) } return $controlResult; } } # SIG # Begin signature block # MIIjiAYJKoZIhvcNAQcCoIIjeTCCI3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBG6hUAWSdXBXmx # yttfApT1rLXJjr2l0ZK0gGShkJytP6CCDYEwggX/MIID56ADAgECAhMzAAAB32vw # LpKnSrTQAAAAAAHfMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjAxMjE1MjEzMTQ1WhcNMjExMjAyMjEzMTQ1WjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQC2uxlZEACjqfHkuFyoCwfL25ofI9DZWKt4wEj3JBQ48GPt1UsDv834CcoUUPMn # s/6CtPoaQ4Thy/kbOOg/zJAnrJeiMQqRe2Lsdb/NSI2gXXX9lad1/yPUDOXo4GNw # PjXq1JZi+HZV91bUr6ZjzePj1g+bepsqd/HC1XScj0fT3aAxLRykJSzExEBmU9eS # yuOwUuq+CriudQtWGMdJU650v/KmzfM46Y6lo/MCnnpvz3zEL7PMdUdwqj/nYhGG # 3UVILxX7tAdMbz7LN+6WOIpT1A41rwaoOVnv+8Ua94HwhjZmu1S73yeV7RZZNxoh # EegJi9YYssXa7UZUUkCCA+KnAgMBAAGjggF+MIIBejAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUOPbML8IdkNGtCfMmVPtvI6VZ8+Mw # UAYDVR0RBEkwR6RFMEMxKTAnBgNVBAsTIE1pY3Jvc29mdCBPcGVyYXRpb25zIFB1 # ZXJ0byBSaWNvMRYwFAYDVQQFEw0yMzAwMTIrNDYzMDA5MB8GA1UdIwQYMBaAFEhu # ZOVQBdOCqhc3NyK1bajKdQKVMFQGA1UdHwRNMEswSaBHoEWGQ2h0dHA6Ly93d3cu # bWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY0NvZFNpZ1BDQTIwMTFfMjAxMS0w # Ny0wOC5jcmwwYQYIKwYBBQUHAQEEVTBTMFEGCCsGAQUFBzAChkVodHRwOi8vd3d3 # Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY0NvZFNpZ1BDQTIwMTFfMjAx # MS0wNy0wOC5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEAnnqH # tDyYUFaVAkvAK0eqq6nhoL95SZQu3RnpZ7tdQ89QR3++7A+4hrr7V4xxmkB5BObS # 0YK+MALE02atjwWgPdpYQ68WdLGroJZHkbZdgERG+7tETFl3aKF4KpoSaGOskZXp # TPnCaMo2PXoAMVMGpsQEQswimZq3IQ3nRQfBlJ0PoMMcN/+Pks8ZTL1BoPYsJpok # t6cql59q6CypZYIwgyJ892HpttybHKg1ZtQLUlSXccRMlugPgEcNZJagPEgPYni4 # b11snjRAgf0dyQ0zI9aLXqTxWUU5pCIFiPT0b2wsxzRqCtyGqpkGM8P9GazO8eao # mVItCYBcJSByBx/pS0cSYwBBHAZxJODUqxSXoSGDvmTfqUJXntnWkL4okok1FiCD # Z4jpyXOQunb6egIXvkgQ7jb2uO26Ow0m8RwleDvhOMrnHsupiOPbozKroSa6paFt # VSh89abUSooR8QdZciemmoFhcWkEwFg4spzvYNP4nIs193261WyTaRMZoceGun7G # CT2Rl653uUj+F+g94c63AhzSq4khdL4HlFIP2ePv29smfUnHtGq6yYFDLnT0q/Y+ # Di3jwloF8EWkkHRtSuXlFUbTmwr/lDDgbpZiKhLS7CBTDj32I0L5i532+uHczw82 # oZDmYmYmIUSMbZOgS65h797rj5JJ6OkeEUJoAVwwggd6MIIFYqADAgECAgphDpDS # AAAAAAADMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMK # V2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0 # IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0 # ZSBBdXRob3JpdHkgMjAxMTAeFw0xMTA3MDgyMDU5MDlaFw0yNjA3MDgyMTA5MDla # MH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS # ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMT # H01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTEwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQCr8PpyEBwurdhuqoIQTTS68rZYIZ9CGypr6VpQqrgG # OBoESbp/wwwe3TdrxhLYC/A4wpkGsMg51QEUMULTiQ15ZId+lGAkbK+eSZzpaF7S # 35tTsgosw6/ZqSuuegmv15ZZymAaBelmdugyUiYSL+erCFDPs0S3XdjELgN1q2jz # y23zOlyhFvRGuuA4ZKxuZDV4pqBjDy3TQJP4494HDdVceaVJKecNvqATd76UPe/7 # 4ytaEB9NViiienLgEjq3SV7Y7e1DkYPZe7J7hhvZPrGMXeiJT4Qa8qEvWeSQOy2u # M1jFtz7+MtOzAz2xsq+SOH7SnYAs9U5WkSE1JcM5bmR/U7qcD60ZI4TL9LoDho33 # X/DQUr+MlIe8wCF0JV8YKLbMJyg4JZg5SjbPfLGSrhwjp6lm7GEfauEoSZ1fiOIl # XdMhSz5SxLVXPyQD8NF6Wy/VI+NwXQ9RRnez+ADhvKwCgl/bwBWzvRvUVUvnOaEP # 6SNJvBi4RHxF5MHDcnrgcuck379GmcXvwhxX24ON7E1JMKerjt/sW5+v/N2wZuLB # l4F77dbtS+dJKacTKKanfWeA5opieF+yL4TXV5xcv3coKPHtbcMojyyPQDdPweGF # RInECUzF1KVDL3SV9274eCBYLBNdYJWaPk8zhNqwiBfenk70lrC8RqBsmNLg1oiM # CwIDAQABo4IB7TCCAekwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFEhuZOVQ # BdOCqhc3NyK1bajKdQKVMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1Ud # DwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFHItOgIxkEO5FAVO # 4eqnxzHRI4k0MFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwubWljcm9zb2Z0 # LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcmwwXgYIKwYBBQUHAQEEUjBQME4GCCsGAQUFBzAChkJodHRwOi8vd3d3Lm1p # Y3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcnQwgZ8GA1UdIASBlzCBlDCBkQYJKwYBBAGCNy4DMIGDMD8GCCsGAQUFBwIB # FjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2RvY3MvcHJpbWFyeWNw # cy5odG0wQAYIKwYBBQUHAgIwNB4yIB0ATABlAGcAYQBsAF8AcABvAGwAaQBjAHkA # XwBzAHQAYQB0AGUAbQBlAG4AdAAuIB0wDQYJKoZIhvcNAQELBQADggIBAGfyhqWY # 4FR5Gi7T2HRnIpsLlhHhY5KZQpZ90nkMkMFlXy4sPvjDctFtg/6+P+gKyju/R6mj # 82nbY78iNaWXXWWEkH2LRlBV2AySfNIaSxzzPEKLUtCw/WvjPgcuKZvmPRul1LUd # d5Q54ulkyUQ9eHoj8xN9ppB0g430yyYCRirCihC7pKkFDJvtaPpoLpWgKj8qa1hJ # Yx8JaW5amJbkg/TAj/NGK978O9C9Ne9uJa7lryft0N3zDq+ZKJeYTQ49C/IIidYf # wzIY4vDFLc5bnrRJOQrGCsLGra7lstnbFYhRRVg4MnEnGn+x9Cf43iw6IGmYslmJ # aG5vp7d0w0AFBqYBKig+gj8TTWYLwLNN9eGPfxxvFX1Fp3blQCplo8NdUmKGwx1j # NpeG39rz+PIWoZon4c2ll9DuXWNB41sHnIc+BncG0QaxdR8UvmFhtfDcxhsEvt9B # xw4o7t5lL+yX9qFcltgA1qFGvVnzl6UJS0gQmYAf0AApxbGbpT9Fdx41xtKiop96 # eiL6SJUfq/tHI4D1nvi/a7dLl+LrdXga7Oo3mXkYS//WsyNodeav+vyL6wuA6mk7 # r/ww7QRMjt/fdW1jkT3RnVZOT7+AVyKheBEyIXrvQQqxP/uozKRdwaGIm1dxVk5I # RcBCyZt2WwqASGv9eZ/BvW1taslScxMNelDNMYIVXTCCFVkCAQEwgZUwfjELMAkG # A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx # HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9z # b2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAxMQITMwAAAd9r8C6Sp0q00AAAAAAB3zAN # BglghkgBZQMEAgEFAKCBsDAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgor # BgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgMPWGj/+e # ieUjlr/pZP+4mD2Z/b45S24Ijw2574cw+wIwRAYKKwYBBAGCNwIBDDE2MDSgFIAS # AE0AaQBjAHIAbwBzAG8AZgB0oRyAGmh0dHBzOi8vd3d3Lm1pY3Jvc29mdC5jb20g # MA0GCSqGSIb3DQEBAQUABIIBACJ7AWwAA5f589SAvZchKke0ziF95R5JzyeC5Xl5 # DmKE565tqSDOaEbOMEPhHWXvkgfYIUjsA4ru7JybEUlrcayBR8yOaiYBRl65hxbV # o61jv6pmd3WR22Yg/62VkWURSuzxdHqKmiFXRZvEj8jY+IrdLBrwdt3zh0z2iH/6 # JdvRno4tAa7WbQt/wghLC74sBUUOIdiuOy4qrCA9z4MISEsM6JEJ9SnSyoImo9PC # o9x8peR5nLXNmJZb+Hp/uLpxWiEggA71Olei9jYAoxTHV5BuSaQOWhPwqeOdj9F+ # m8aqaGb0KKqIP8gYdhhDBeAj5a9geneiwVBAQn0s6KRRpN6hghLlMIIS4QYKKwYB # BAGCNwMDATGCEtEwghLNBgkqhkiG9w0BBwKgghK+MIISugIBAzEPMA0GCWCGSAFl # AwQCAQUAMIIBUQYLKoZIhvcNAQkQAQSgggFABIIBPDCCATgCAQEGCisGAQQBhFkK # AwEwMTANBglghkgBZQMEAgEFAAQgGFsyprCn6lZrK6r8MUNZPoixfgUe+7bBtR3W # T6KYxLgCBmD5hU0itxgTMjAyMTA4MTYwNTUzNDIuMDg5WjAEgAIB9KCB0KSBzTCB # yjELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl # ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMc # TWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9uczEmMCQGA1UECxMdVGhhbGVzIFRT # UyBFU046OEE4Mi1FMzRGLTlEREExJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0 # YW1wIFNlcnZpY2Wggg48MIIE8TCCA9mgAwIBAgITMwAAAUtPsqZI1eTCUQAAAAAB # SzANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAe # Fw0yMDExMTIxODI1NTlaFw0yMjAyMTExODI1NTlaMIHKMQswCQYDVQQGEwJVUzET # MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV # TWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmlj # YSBPcGVyYXRpb25zMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjo4QTgyLUUzNEYt # OUREQTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZTCCASIw # DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKE2elDHdi4mv+K+hs+gu2lD16BQ # WXxMd1ZnpIAogl20/cvbgPf93reiaaaNmMLKtCb6P/W0cMDCNAa47Bi+fv15w8JB # 8AH3UmcSn/A/gEwXZJfIx/yT1HzhG2Eh18Yc9dNarOkIJ81aiVURxRWbwB3+vUuu # KRE77goqjqyUNAkqyAoCl8FT/0ntG52+HDWsRDDQ2TUFEZaOsinv+5ahQh9HityX # pTW606JgiicLzs8+kAlBcZGwN0qdUUXg2la8yLJ66Syfm3863DPzawaWd78c1CmY # zOKBHxxnx5cQMkk0hnGi/1YAcePbyBQTb0PyK8BPvTqKHG9O/nRljxbnW7ECAwEA # AaOCARswggEXMB0GA1UdDgQWBBRSqmp+0BKW57orct4+VNOfTUrrxjAfBgNVHSME # GDAWgBTVYzpcijGQ80N7fEYbxTNoWoVtVTBWBgNVHR8ETzBNMEugSaBHhkVodHRw # Oi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9NaWNUaW1TdGFQ # Q0FfMjAxMC0wNy0wMS5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsGAQUFBzAChj5o # dHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1RpbVN0YVBDQV8y # MDEwLTA3LTAxLmNydDAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMI # MA0GCSqGSIb3DQEBCwUAA4IBAQAW2rnVlz87UB8kri0QHY2vxsYRUPmpDyXyBchA # ysxli110cf5waKqAX/gaa+Y9+XkUBiH6B//xh3erj+IPb4rgu0luz/e/qanIGXWZ # Di+6wrrl0DKlaaJPVbcWJeOyYIiSNIMOwosUFgfnIYWc0U4QyAv47u7iiwfjZ/zS # dzZZ2dlXr469bTflc9Xpm21QF8VYd0htSR04bU7afjImbXQ59pwi1nTx/OAwyoT5 # /9JOBVY0IdtHYRipNZrKsY/r2MzC1UP0EYZNa2LVeOm8TrIp07wf2e5GLcv4LqNi # e19oSYFNudMURX6RHHUI1ylJv2izzoIBR6FlTVpHNDoJD+mPMIIGcTCCBFmgAwIB # AgIKYQmBKgAAAAAAAjANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzAR # BgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1p # Y3Jvc29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2Vy # dGlmaWNhdGUgQXV0aG9yaXR5IDIwMTAwHhcNMTAwNzAxMjEzNjU1WhcNMjUwNzAx # MjE0NjU1WjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4G # A1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYw # JAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDCCASIwDQYJKoZI # hvcNAQEBBQADggEPADCCAQoCggEBAKkdDbx3EYo6IOz8E5f1+n9plGt0VBDVpQoA # goX77XxoSyxfxcPlYcJ2tz5mK1vwFVMnBDEfQRsalR3OCROOfGEwWbEwRA/xYIiE # VEMM1024OAizQt2TrNZzMFcmgqNFDdDq9UeBzb8kYDJYYEbyWEeGMoQedGFnkV+B # VLHPk0ySwcSmXdFhE24oxhr5hoC732H8RsEnHSRnEnIaIYqvS2SJUGKxXf13Hz3w # V3WsvYpCTUBR0Q+cBj5nf/VmwAOWRH7v0Ev9buWayrGo8noqCjHw2k4GkbaICDXo # eByw6ZnNPOcvRLqn9NxkvaQBwSAJk3jN/LzAyURdXhacAQVPIk0CAwEAAaOCAeYw # ggHiMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBTVYzpcijGQ80N7fEYbxTNo # WoVtVTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYD # VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvXzpoYxDBW # BgNVHR8ETzBNMEugSaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2Ny # bC9wcm9kdWN0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYIKwYBBQUH # AQEETjBMMEoGCCsGAQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtp # L2NlcnRzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNydDCBoAYDVR0gAQH/BIGV # MIGSMIGPBgkrBgEEAYI3LgMwgYEwPQYIKwYBBQUHAgEWMWh0dHA6Ly93d3cubWlj # cm9zb2Z0LmNvbS9QS0kvZG9jcy9DUFMvZGVmYXVsdC5odG0wQAYIKwYBBQUHAgIw # NB4yIB0ATABlAGcAYQBsAF8AUABvAGwAaQBjAHkAXwBTAHQAYQB0AGUAbQBlAG4A # dAAuIB0wDQYJKoZIhvcNAQELBQADggIBAAfmiFEN4sbgmD+BcQM9naOhIW+z66bM # 9TG+zwXiqf76V20ZMLPCxWbJat/15/B4vceoniXj+bzta1RXCCtRgkQS+7lTjMz0 # YBKKdsxAQEGb3FwX/1z5Xhc1mCRWS3TvQhDIr79/xn/yN31aPxzymXlKkVIArzgP # F/UveYFl2am1a+THzvbKegBvSzBEJCI8z+0DpZaPWSm8tv0E4XCfMkon/VWvL/62 # 5Y4zu2JfmttXQOnxzplmkIz/amJ/3cVKC5Em4jnsGUpxY517IW3DnKOiPPp/fZZq # kHimbdLhnPkd/DjYlPTGpQqWhqS9nhquBEKDuLWAmyI4ILUl5WTs9/S/fmNZJQ96 # LjlXdqJxqgaKD4kWumGnEcua2A5HmoDF0M2n0O99g/DhO3EJ3110mCIIYdqwUB5v # vfHhAN/nMQekkzr3ZUd46PioSKv33nJ+YWtvd6mBy6cJrDm77MbL2IK0cs0d9LiF # AR6A+xuJKlQ5slvayA1VmXqHczsI5pgt6o3gMy4SKfXAL1QnIffIrE7aKLixqduW # sqdCosnPGUFN4Ib5KpqjEWYw07t0MkvfY3v1mYovG8chr1m1rtxEPJdQcdeh0sVV # 42neV8HR3jDA/czmTfsNv11P6Z0eGTgvvM9YBS7vDaBQNdrvCScc1bN+NR4Iuto2 # 29Nfj950iEkSoYICzjCCAjcCAQEwgfihgdCkgc0wgcoxCzAJBgNVBAYTAlVTMRMw # EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN # aWNyb3NvZnQgQ29ycG9yYXRpb24xJTAjBgNVBAsTHE1pY3Jvc29mdCBBbWVyaWNh # IE9wZXJhdGlvbnMxJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNOOjhBODItRTM0Ri05 # RERBMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNloiMKAQEw # BwYFKw4DAhoDFQCROjP3t+x4fE05RJDk79sFVIX57qCBgzCBgKR+MHwxCzAJBgNV # BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w # HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29m # dCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3DQEBBQUAAgUA5MRPvTAiGA8y # MDIxMDgxNjEwNDUxN1oYDzIwMjEwODE3MTA0NTE3WjB3MD0GCisGAQQBhFkKBAEx # LzAtMAoCBQDkxE+9AgEAMAoCAQACAikNAgH/MAcCAQACAhE4MAoCBQDkxaE9AgEA # MDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKgCjAIAgEAAgMHoSChCjAI # AgEAAgMBhqAwDQYJKoZIhvcNAQEFBQADgYEAbh0v03TcugWN4bHyKFwrikKfEsbd # CtRo4EcNbKzsTAy3FTZeJdqMKy5J3L0l1jelRUjK2X0H+VuYYJ1MDbGo+OePpTVZ # VLhpsLqd68C/jYdvv1yDBgWiVPffin5awTevc8dQtsGjTVVEyavPmzg2BG0jlHjz # UIzjrPENudQfsX0xggMNMIIDCQIBATCBkzB8MQswCQYDVQQGEwJVUzETMBEGA1UE # CBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9z # b2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQ # Q0EgMjAxMAITMwAAAUtPsqZI1eTCUQAAAAABSzANBglghkgBZQMEAgEFAKCCAUow # GgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJBDEiBCAHFK2h # 3QIGcH3ziw6BYZbvVC+xbpVK8DEk/KPa3EggTjCB+gYLKoZIhvcNAQkQAi8xgeow # gecwgeQwgb0EIGv27oQieexlgS2z8WP+sgW/RhlbXKeFco4/aFU9RTkjMIGYMIGA # pH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT # B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE # AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAFLT7KmSNXkwlEA # AAAAAUswIgQgS1mGnjMfr/kFoqfsPPw2exacwidDI8udX+hMNj9UE7swDQYJKoZI # hvcNAQELBQAEggEAT83lXZ8PebwZdxyVyeWGh60jq3P/qxb9SF7ottoyjRaQWN0e # s7hrtgTy7IOgKfCPlDh/+M+YKZk0DgWfafRbEc/57grf67RMeyj+N31IVk86A+Bd # WvXBOeSnzzJO9OEYBTYfKr7VjQj+BemGZts3Z5E9djXc4mTfUviLd/fHDL9L8d7D # twCp+dzEmko5rBNdpR/DeOwCWuoyQzxPKefUX75O8NySvgh97hUzKVlMPql0+Edq # mZdcdFN4EEbQxTyggRTqIlknDdf1WGFZEW0hViHRcGKhnq7n8X0qHRdtXqGQoAFm # fwmo19FCqzbhCnAIoquXMEvrKuWbGT4rWQ8GHw== # SIG # End signature block |