Framework/Core/SVT/ADO/ADO.ServiceConnection.ps1
Set-StrictMode -Version Latest class ServiceConnection: ADOSVTBase { hidden [PSObject] $ServiceEndpointsObj = $null; hidden static [string] $SecurityNamespaceId = $null; hidden [PSObject] $ProjectId; hidden [PSObject] $ServiceConnEndPointDetail = $null; hidden [PSObject] $pipelinePermission = $null; hidden [PSObject] $serviceEndPointIdentity = $null; hidden [PSObject] $SvcConnActivityDetail = @{isSvcConnActive = $true; svcConnLastRunDate = $null; message = $null; isComputed = $false; errorObject = $null}; hidden static $IsOAuthScan = $false; hidden [string] $checkInheritedPermissionsPerSvcConn = $false ServiceConnection([string] $organizationName, [SVTResource] $svtResource): Base($organizationName,$svtResource) { if(-not [string]::IsNullOrWhiteSpace($env:RefreshToken) -and -not [string]::IsNullOrWhiteSpace($env:ClientSecret)) # this if block will be executed for OAuth based scan { [ServiceConnection]::IsOAuthScan = $true } # Get project id $this.ProjectId = ($this.ResourceContext.ResourceId -split "project/")[-1].Split('/')[0] # Get security namespace identifier of service endpoints. if([string]::IsNullOrEmpty([ServiceConnection]::SecurityNamespaceId)) { $apiURL = "https://dev.azure.com/{0}/_apis/securitynamespaces?api-version=6.0" -f $($this.OrganizationContext.OrganizationName) $securityNamespacesObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); [ServiceConnection]::SecurityNamespaceId = ($securityNamespacesObj | Where-Object { ($_.Name -eq "ServiceEndpoints")}).namespaceId $securityNamespacesObj = $null; } # Get service connection details https://dev.azure.com/{organization}/{project}/_admin/_services $this.ServiceEndpointsObj = $this.ResourceContext.ResourceDetails if(($this.ServiceEndpointsObj | Measure-Object).Count -eq 0) { throw [SuppressedException] "Unable to find active service connection(s) under [$($this.ResourceContext.ResourceGroupName)] project." } # if service connection activity check function is not computed, then first compute the function to get the correct status of service connection. if($this.SvcConnActivityDetail.isComputed -eq $false) { $this.CheckActiveConnection() } # overiding the '$this.isResourceActive' global variable based on the current status of service connection . if ($this.SvcConnActivityDetail.isSvcConnActive) { $this.isResourceActive = $true } else { $this.isResourceActive = $false } # calculating the inactivity period in days for the service connection. If there is no usage history, then setting it with negative value. # This will ensure inactive period is always computed irrespective of whether inactive control is scanned or not. if ($null -ne $this.SvcConnActivityDetail.svcConnLastRunDate) { $this.InactiveFromDays = ((Get-Date) - $this.SvcConnActivityDetail.svcConnLastRunDate).Days } if ([Helpers]::CheckMember($this.ControlSettings, "ServiceConnection.CheckForInheritedPermissions") -and $this.ControlSettings.ServiceConnection.CheckForInheritedPermissions) { $this.checkInheritedPermissionsPerSvcConn = $true } } [ControlItem[]] ApplyServiceFilters([ControlItem[]] $controls) { $result = $controls; # Applying filter to exclude certain controls based on Tag #For non azurerm svc conn - filter out all controls that are specific to azurerm if($this.ServiceEndpointsObj.type -ne "azurerm") { $result = $result | Where-Object { $_.Tags -notcontains "AzureRM" }; } #For non azure svc conn - filter out all controls that are specific to azure if($this.ServiceEndpointsObj.type -ne "azure") { $result = $result | Where-Object { $_.Tags -notcontains "Azure" }; } #if svc conn is either azure/azurerm - some controls that are specific and common to both azure/azurerm should be readded as they might have been filtered out in one of the previous two if conditions. if(($this.ServiceEndpointsObj.type -eq "azurerm") -or ($this.ServiceEndpointsObj.type -eq "azure")) { $result += $controls | Where-Object { ($_.Tags -contains "AzureRM") -and ($_.Tags -contains "Azure") }; } return $result; } hidden [ControlResult] CheckServiceConnectionAccess([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if ($this.ServiceEndpointsObj.type -eq "azurerm") { try { if([Helpers]::CheckMember($this.ServiceEndpointsObj, "data") ) { $message = "Service connection has access at [{0}] {1} scope in the subscription [{2}] ."; $serviceEndPoint = $this.ServiceEndpointsObj # 'scopeLevel' and 'creationMode' properties are required to determine whether a svc conn is automatic or manual. # irrespective of creationMode - pass the control for conn authorized at MLWorkspace and PublishProfile (app service) scope as such conn are granted access at resource level. if(([Helpers]::CheckMember($serviceEndPoint, "data.scopeLevel") -and ([Helpers]::CheckMember($serviceEndPoint.data, "creationMode")) )) { #If Service connection creation mode is 'automatic' and scopeLevel is subscription and no resource group is defined in its access definition -> conn has subscription level access -> fail the control, #else pass the control if scopeLevel is 'Subscription' and 'scope' is RG (note scope property is visible, only if conn is authorized to an RG) #Fail the control if it has access to management group (last condition) if(($serviceEndPoint.data.scopeLevel -eq "Subscription" -and $serviceEndPoint.data.creationMode -eq "Automatic" -and !([Helpers]::CheckMember($serviceEndPoint.authorization,"parameters.scope") )) -or ($serviceEndPoint.data.scopeLevel -eq "ManagementGroup")) { $controlFailedMsg = ''; if ($serviceEndPoint.data.scopeLevel -eq "Subscription") { $controlFailedMsg = "Service connection has access at [$($serviceEndPoint.data.subscriptionName)] subscription scope." } elseif ($serviceEndPoint.data.scopeLevel -eq "ManagementGroup") { $controlFailedMsg = "Service connection has access at [$($serviceEndPoint.data.managementGroupName)] management group scope." } $controlResult.AddMessage([VerificationResult]::Failed, $controlFailedMsg); $controlResult.AdditionalInfo += $controlFailedMsg; } else{ # else gets executed when svc is scoped at RG and not at sub or MG if ([Helpers]::CheckMember($serviceEndPoint.authorization.parameters, "scope")) { $message = $message -f $serviceEndPoint.authorization.parameters.scope.split('/')[-1], 'resource group', $serviceEndPoint.data.subscriptionName } else { $message = "Service connection is not configured at subscription scope." } $controlResult.AddMessage([VerificationResult]::Passed, $message); $controlResult.AdditionalInfo += $message; } } #elseif gets executed when scoped at AzureMLWorkspace elseif(([Helpers]::CheckMember($serviceEndPoint, "data.scopeLevel") -and $serviceEndPoint.data.scopeLevel -eq "AzureMLWorkspace")) { $message = $message -f $serviceEndPoint.data.mlWorkspaceName, 'ML workspace', $serviceEndPoint.data.subscriptionName $controlResult.AddMessage([VerificationResult]::Passed, $message); $controlResult.AdditionalInfo += $message; } #elseif gets executed when scoped at PublishProfile elseif(([Helpers]::CheckMember($serviceEndPoint, "authorization.scheme") -and $serviceEndPoint.authorization.scheme -eq "PublishProfile")) { $message = $message -f $serviceEndPoint.data.resourceId.split('/')[-1], 'app service', $serviceEndPoint.data.subscriptionName $controlResult.AddMessage([VerificationResult]::Passed, $message); $controlResult.AdditionalInfo += $message; } else # if creation mode is manual and type is other (eg. managed identity) then verify the control { $controlResult.AddMessage([VerificationResult]::Verify, "Access scope of service connection can not be verified as it is not an 'automatic' service prinicipal."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the service connection details."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the service connection details."); $controlResult.LogException($_) } } else { $controlResult.AddMessage([VerificationResult]::Manual,"Access scope of service connections of type other than 'Azure Resource Manager' can not be verified."); } return $controlResult; } hidden [ControlResult] CheckClassicConnection([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.ServiceEndpointsObj.type -eq "azure") { $controlResult.AddMessage([VerificationResult]::Failed, "Classic service connection detected."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Classic service connection not detected."); } return $controlResult; } hidden [ControlResult] CheckSPNAuthenticationCertificate([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.ServiceEndpointsObj, "authorization.parameters.authenticationType")) { if( $this.ServiceEndpointsObj.authorization.parameters.authenticationType -eq "spnKey") { $controlResult.AddMessage([VerificationResult]::Failed, "Service endpoint is authenticated using secret."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Service endpoint is authenticated using certificate."); } } return $controlResult; } hidden [ControlResult] CheckInheritedPermissions ([ControlResult] $controlResult) { $failMsg = $null try { $Endpoint = $this.ServiceEndpointsObj $apiURL = "https://dev.azure.com/{0}/_apis/accesscontrollists/{1}?token=endpoints/{2}/{3}&api-version=6.0" -f $($this.OrganizationContext.OrganizationName),$([ServiceConnection]::SecurityNamespaceId),$($this.ProjectId),$($Endpoint.id); $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); if(($responseObj | Measure-Object).Count -eq 0) { $inheritPermissionsEnabled += @{EndPointName= $Endpoint.Name; Creator = $Endpoint.createdBy.displayName; inheritPermissions="Unable to fetch permissions inheritance details." } } elseif([Helpers]::CheckMember($responseObj,"inheritPermissions") -and $responseObj.inheritPermissions -eq $true) { $controlResult.AddMessage([VerificationResult]::Failed,"Inherited permissions are enabled on service connection."); } else { $controlResult.AddMessage([VerificationResult]::Passed,"Inherited permissions are disabled on service connection."); } $Endpoint = $null; $responseObj = $null; } catch { $failMsg = $_ $controlResult.LogException($_) } if(![string]::IsNullOrEmpty($failMsg)) { $controlResult.AddMessage([VerificationResult]::Manual,"Unable to fetch service connections details. $($failMsg)Please verify from portal that permission inheritance is turned OFF for all the service connections"); } return $controlResult; } hidden [ControlResult] CheckGlobalGroupsAddedToServiceConnections ([ControlResult] $controlResult) { # Any identity other than teams identity needs to be verified manually as it's details cannot be retrived using API $controlResult.VerificationResult = [VerificationResult]::Failed try { if ($null -eq $this.serviceEndPointIdentity) { $apiURL = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/{1}_{2}" -f $($this.OrganizationContext.OrganizationName), $($this.ProjectId),$($this.ServiceEndpointsObj.id); $this.serviceEndPointIdentity = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); } $restrictedGroups = @(); $restrictedGlobalGroupsForSerConn = $this.ControlSettings.ServiceConnection.RestrictedGlobalGroupsForSerConn; if([Helpers]::CheckMember($this.serviceEndPointIdentity,"identity")) { # match all the identities added on service connection with defined restricted list $restrictedGroups = $this.serviceEndPointIdentity.identity | Where-Object { $restrictedGlobalGroupsForSerConn -contains $_.displayName.split('\')[-1] } | select displayName # fail the control if restricted group found on service connection if($restrictedGroups) { $controlResult.AddMessage("Count of global groups that have access to service connection: ", @($restrictedGroups).Count) $controlResult.AddMessage([VerificationResult]::Failed,"Do not grant global groups access to service connections. Granting elevated permissions to these groups can risk exposure of service connections to unwarranted individuals."); $controlResult.AddMessage("Global groups that have access to service connection.",$restrictedGroups) $controlResult.SetStateData("Global groups that have access to service connection",$restrictedGroups) $controlResult.AdditionalInfo += "Count of global groups that have access to service connection: " + @($restrictedGroups).Count; $groups = $restrictedGroups.displayname -join ' ; ' $controlResult.AdditionalInfoInCSV = "List of global groups: $($groups)" } else{ $controlResult.AddMessage([VerificationResult]::Passed,"No global groups have access to service connection."); } } else { $controlResult.AddMessage([VerificationResult]::Passed,"No global groups have access to service connection."); } $restrictedGroups = $null; $restrictedGlobalGroupsForSerConn = $null; } catch { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch service connections details.") $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBuildServiceAccountAccess([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed $failMsg = $null try { #$isBuildSvcAccGrpFound = $false $buildServieAccountOnSvc = @(); if ($null -eq $this.serviceEndPointIdentity) { $apiURL = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/{1}_{2}" -f $($this.OrganizationContext.OrganizationName), $($this.ProjectId),$($this.ServiceEndpointsObj.id); $this.serviceEndPointIdentity = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); } if(($this.serviceEndPointIdentity.Count -gt 0) -and [Helpers]::CheckMember($this.serviceEndPointIdentity[0],"identity")) { foreach ($endPointidentity in $this.serviceEndPointIdentity) { if ($endPointidentity.identity.displayName -like '*Project Collection Build Service Accounts' -or $endPointidentity.identity.displayName -like "*Build Service ($($this.OrganizationContext.OrganizationName))") { $buildServieAccountOnSvc += $endPointidentity; #$isBuildSvcAccGrpFound = $true; #break; } } #Faile the control if prj coll Buil Ser Acc Group Found added on serv conn $restrictedBuildSVCAcctCount = $buildServieAccountOnSvc.Count; if($restrictedBuildSVCAcctCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Count of restricted Build Service groups that have access to service connection: $($restrictedBuildSVCAcctCount)") $formattedBSAData = $($buildServieAccountOnSvc.identity.displayName | FT | out-string ) #$formattedGroupsTable = ($formattedGroupsData | Out-String) $controlResult.AddMessage("`nList of 'Build Service' Accounts: ", $formattedBSAData) $controlResult.SetStateData("List of 'Build Service' Accounts: ", $formattedBSAData) $controlResult.AdditionalInfo += "Count of restricted Build Service groups that have access to service connection: $($restrictedBuildSVCAcctCount)"; $formatedMembers = $buildServieAccountOnSvc | ForEach-Object { $_.identity.displayName + ': ' + $_.role.displayName } $controlResult.AdditionalInfoInCSV = $(($formatedMembers) -join '; ') if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired){ $buildServiceAccountId = @($buildServieAccountOnSvc | Select-Object -property @{name= "Id";expression = {$_.identity.id}}, @{name = "Group"; expression = {$_.identity.displayName}}, @{name = "Role"; expression ={$_.role.name}}) $controlResult.BackupControlState = $buildServiceAccountId } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBuildServiceAccountAccessAutomatedFix($controlResult); } } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Build Service accounts are not granted access to the service connection."); $controlResult.AdditionalInfoInCSV = "NA"; } $controlResult.AddMessage("`nNote:`nThe following 'Build Service' accounts should not have access to service connection: `nProject Collection Build Service Account`n$($this.ResourceContext.ResourceGroupName) Build Service ($($this.OrganizationContext.OrganizationName))"); } else{ $controlResult.AddMessage([VerificationResult]::Verify,"Unable to fetch service endpoint group identity."); # Will occur if no user permission exists on the svc } } catch { $failMsg = $_ $controlResult.LogException($_) } if(![string]::IsNullOrEmpty($failMsg)) { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch service connections details. $($failMsg)Please verify from portal that you are not granting global security groups access to service connections"); } return $controlResult; } hidden [ControlResult] CheckBuildServiceAccountAccessAutomatedFix([ControlResult] $controlResult){ try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } $body = "[" #method name will be different in undofix $MethodName = "Patch" if (-not $this.UndoFix){ Foreach($_ in $RawDataObjForControlFix){ if ($body.length -gt 1) {$body += ","} $body += '"'+$($_.Id)+'"' } } else{ Foreach($_ in $RawDataObjForControlFix){ if ($body.length -gt 1) {$body += ","} $body += @" { "roleName": "$($_.Role)", "userId": "$($_.Id)" } "@ } $MethodName = "Put" } $body+="]" $url = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/{1}_{2}?api-version=6.1-preview.1" -f $($this.OrganizationContext.OrganizationName), $($this.ProjectId),$($this.ServiceEndpointsObj.id); $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($url) Invoke-RestMethod -Uri $url -Method $MethodName -ContentType "application/json" -Headers $header -Body $body if (-not $this.UndoFix){ $controlResult.AddMessage([VerificationResult]::Fixed, "Following Build Service accounts have been removed from user permissions: "); } else{ $controlResult.AddMessage([VerificationResult]::Fixed, "Following Build Service accounts have been added in user permissions: "); } $display = ($RawDataObjForControlFix | Select-Object -property @{name="Group"; expression ={$_.Group}}, @{name = 'Role'; expression = {$_.Role}} | 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] CheckServiceConnectionBuildAccess([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { if ($null -eq $this.pipelinePermission) { $apiURL = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/endpoint/{2}?api-version=6.1-preview.1" -f $($this.OrganizationContext.OrganizationName),$($this.ProjectId),$($this.ServiceEndpointsObj.id) ; $this.pipelinePermission = [WebRequestHelper]::InvokeGetWebRequest($apiURL); } if([Helpers]::CheckMember($this.pipelinePermission,"allPipelines")) { if($this.pipelinePermission.allPipelines.authorized){ $controlResult.AddMessage([VerificationResult]::Failed,"Service connection is accessible to all YAML pipelines."); if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired){ $controlResult.BackupControlState = $this.pipelinePermission; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckServiceConnectionBuildAccessAutomatedFix($controlResult); } } else { $controlResult.AddMessage([VerificationResult]::Passed,"Service connection is not accessible to all YAML pipelines."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection is not accessible to all YAML pipelines."); } $controlResult.AdditionalInfoInCSV = "NA"; } catch { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch service connection details. $($_) Please verify from portal that you are not granting all pipeline access to service connections"); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckServiceConnectionBuildAccessAutomatedFix([ControlResult] $controlResult) { try{ $this.PublishCustomMessage( "`nAfter applying this fix, any YAML pipelines using this service connection will lose access. You will have to explicitly add them.", [MessageType]::Warning); $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $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/endpoint/{2}?api-version=5.1-preview.1" -f ($this.OrganizationContext.OrganizationName), $($this.ProjectId), $($this.ServiceEndpointsObj.id); $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($uri) $Result = Invoke-RestMethod -Uri $uri -Method Patch -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Service connection is not accessible to all YAML pipelines."); } else { $body = $RawDataObjForControlFix | ConvertTo-Json -Depth 10; $uri = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/endpoint/{2}?api-version=5.1-preview.1" -f ($this.OrganizationContext.OrganizationName), $($this.ProjectId), $($this.ServiceEndpointsObj.id); $header = [WebRequestHelper]::GetAuthHeaderFromUriPatch($uri) $Result = Invoke-RestMethod -Uri $uri -Method Patch -ContentType "application/json" -Headers $header -Body $body $controlResult.AddMessage([VerificationResult]::Fixed, "Service connection is accessible to all YAML pipelines."); } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not apply fix."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckSecureAuthN([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.ServiceEndpointsObj, "authorization.scheme")) { if($this.ServiceEndpointsObj.type -eq "github") { #Nov 2020 - Currently, authorizing using OAuth, permissions are fixed (high privileges by default) and can not be modified. If authorized using PAT, we can not determine whether it is a full scope or custom access scope token. if( $this.ServiceEndpointsObj.authorization.scheme -eq "OAuth") { $controlResult.AddMessage([VerificationResult]::Verify, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } else { $controlResult.AddMessage([VerificationResult]::Verify, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } } elseif($this.ServiceEndpointsObj.type -eq "azure") { if( $this.ServiceEndpointsObj.authorization.scheme -eq "Certificate") { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); $controlResult.AddMessage("Certificate based authentication should be used for Azure Classic service connection.") } } elseif($this.ServiceEndpointsObj.type -eq "azurerm") { $controlResult.AddMessage([VerificationResult]::Verify, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } elseif($this.ServiceEndpointsObj.type -eq "externalnpmregistry") { if( $this.ServiceEndpointsObj.authorization.scheme -eq "Token") { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); $controlResult.AddMessage("Token based authentication should be used for NPM service connection.") } } elseif($this.ServiceEndpointsObj.type -eq "externalnugetfeed") { if( $this.ServiceEndpointsObj.authorization.scheme -eq "None") #APIKey { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); $controlResult.AddMessage("ApiKey based authentication should be used for NuGet service connection.") } } elseif($this.ServiceEndpointsObj.type -eq "externaltfs") { if( $this.ServiceEndpointsObj.authorization.scheme -eq "Token") { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); $controlResult.AddMessage("Token based authentication should be used for Azure Repos/Team Foundation Server service connection.") } } elseif($this.ServiceEndpointsObj.type -eq "MicrosoftSwagger") { if( $this.ServiceEndpointsObj.authorization.scheme -eq "Token") { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Service connection [$($this.ServiceEndpointsObj.name)] is authenticated via $($this.ServiceEndpointsObj.authorization.scheme)."); $controlResult.AddMessage("Token based authentication should be used for Microsoft Swagger service connection.") } } else { $controlResult.AddMessage([VerificationResult]::NotScanned,"Control is not applicable to [$($this.ServiceEndpointsObj.name)] service connection."); } } return $controlResult; } hidden [ControlResult] CheckInactiveConnection([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { if ($this.SvcConnActivityDetail.message -eq 'Could not fetch the service connection details.') { $controlResult.AddMessage([VerificationResult]::Error, $this.SvcConnActivityDetail.message); if ($null -ne $this.SvcConnActivityDetail.errorObject) { $controlResult.LogException($this.SvcConnActivityDetail.errorObject) } } elseif ($null -ne $this.SvcConnActivityDetail.svcConnLastRunDate) { if ($this.SvcConnActivityDetail.isSvcConnActive) { $controlResult.AddMessage([VerificationResult]::Passed, $this.SvcConnActivityDetail.message); } else { $controlResult.AddMessage([VerificationResult]::Failed, $this.SvcConnActivityDetail.message); } $formattedDate = $this.SvcConnActivityDetail.svcConnLastRunDate.ToString("d MMM yyyy") $controlResult.AddMessage("Last usage date of service connection: $($formattedDate )"); $controlResult.AdditionalInfo += "Last usage date of service connection: " + $formattedDate ; $SvcConnInactivePeriod = ((Get-Date) - $this.SvcConnActivityDetail.svcConnLastRunDate).Days $controlResult.AdditionalInfoInCSV += "InactiveDays: $($SvcConnInactivePeriod)"; $controlResult.AddMessage("The service connection was inactive from last $($SvcConnInactivePeriod) days."); } elseif ($this.SvcConnActivityDetail.isSvcConnActive) { $controlResult.AddMessage([VerificationResult]::Passed, $this.SvcConnActivityDetail.message); $controlResult.AdditionalInfoInCSV = "NA"; } else { $controlResult.AddMessage([VerificationResult]::Failed, $this.SvcConnActivityDetail.message); $controlResult.AdditionalInfoInCSV += "Serivce connection last run date not found."; } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch the service connection details."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckCrossProjectSharing([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.serviceendpointsobj -and [Helpers]::CheckMember($this.serviceendpointsobj, "serviceEndpointProjectReferences") ) { #Get the project list which are accessible to the service connection. $svcProjectReferences = $this.serviceendpointsobj.serviceEndpointProjectReferences if (($svcProjectReferences | Measure-Object).Count -gt 1) { $stateData = @(); $stateData += $svcProjectReferences | Select-Object name, projectReference $controlResult.AddMessage("`nCount of projects that have access to the service connection: $($stateData.Count)") ; $display = $stateData.projectReference | FT @{l='ProjectId';e={$_.id}},@{l='ProjectName';e={$_.name}} -AutoSize | Out-String -Width 512 $controlResult.AddMessage([VerificationResult]::Failed, "Review the list of projects that have access to the service connection: ", $display); $controlResult.SetStateData("List of projects that have access to the service connection: ", $stateData); $controlResult.AdditionalInfo += "Count of projects that have access to the service connection: $($stateData.Count)"; $controlResult.AdditionalInfo += "List of projects that have access to the service connection: " + [JsonHelper]::ConvertToJsonCustomCompressed($stateData); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection is not shared with multiple projects."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Service connection details could not be fetched."); } return $controlResult; } hidden [ControlResult] CheckCrossPipelineSharing([ControlResult] $controlResult) { try { if ($null -eq $this.pipelinePermission) { #Get pipeline access on svc conn $apiURL = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/endpoint/{2}?api-version=6.1-preview.1" -f $($this.OrganizationContext.OrganizationName), $($this.ProjectId), $($this.ServiceEndpointsObj.id) ; $this.pipelinePermission = [WebRequestHelper]::InvokeGetWebRequest($apiURL); } #check if svc conn is set to "Grant access permission to all pipelines" if ([Helpers]::CheckMember($this.pipelinePermission[0], "allPipelines.authorized") -and $this.pipelinePermission[0].allPipelines.authorized -eq $true) { $controlResult.AddMessage([VerificationResult]::Failed, "Service connection is accessible to all pipelines in the project."); } elseif ([Helpers]::CheckMember($this.pipelinePermission[0], "pipelines") -and ($this.pipelinePermission[0].pipelines | Measure-Object).Count -gt 1) #Atleast one pipeline has access to svvc conn { #get the pipelines ids in comma separated string to pass in api to get the pipeline name $pipelinesIds = $this.pipelinePermission[0].pipelines.id -join "," #api call to get the pipeline name $apiURL = "https://dev.azure.com/{0}/{1}/_apis/build/definitions?definitionIds={2}&api-version=6.0" -f $($this.OrganizationContext.OrganizationName), $($this.ProjectId), $pipelinesIds; $pipelineObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); # We are fixing the control status here and the state data info will be done as shown below. This is done in case we are not able to fetch the pipeline names. Although, we have the pipeline ids as shown above. $controlResult.AddMessage([VerificationResult]::Verify, ""); $pipelines = @(); if ($pipelineObj -and ($pipelineObj | Measure-Object).Count -gt 0) { $pipelines += $pipelineObj.name $controlResult.AddMessage("Total number of pipelines that have access to the service connection: ", ($pipelines | Measure-Object).Count); $controlResult.AddMessage("Review the list of pipelines that have access to the service connection: ", $pipelines); $controlResult.SetStateData("List of pipelines that have access to the service connection: ", $pipelines); $controlResult.AdditionalInfo += "Total number of pipelines that have access to the service connection: " + ($pipelines | Measure-Object).Count; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection is not shared with multiple pipelines."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch pipeline permission details for the service connection."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckRBACAccess([ControlResult] $controlResult) { <# { "ControlID": "ADO_ServiceConnection_AuthZ_Grant_Min_RBAC_Access", "Description": "Justify all users/groups that have access to the service connection.", "Id": "ServiceConnection130", "ControlSeverity": "High", "Automated": "Yes", "MethodName": "CheckRBACAccess", "Rationale": "Granting minimum access by leveraging RBAC feature ensures that users/groups are granted just enough permissions on service connection to perform their tasks. This minimizes exposure of the resources in case of user/service account compromise.", "Recommendation": "Go to Project Settings --> Pipelines --> Service Connections --> Select Service Connection --> Select three dots on top right --> Select Security --> Under user permissions verify role assignments", "Tags": [ "SDL", "TCP", "Manual", "AuthZ" ], "Enabled": true } #> try { if ($null -eq $this.serviceEndPointIdentity) { $apiURL = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/{1}_{2}" -f $($this.OrganizationContext.OrganizationName), $($this.ProjectId),$($this.ServiceEndpointsObj.id); $this.serviceEndPointIdentity = [WebRequestHelper]::InvokeGetWebRequest($apiURL); } if((($this.serviceEndPointIdentity | Measure-Object).Count -gt 0) -and [Helpers]::CheckMember($this.serviceEndPointIdentity[0],"identity")) { $roles = @(); $roles += ($this.serviceEndPointIdentity | Select-Object -Property @{Name="Name"; Expression = {$_.identity.displayName}},@{Name="Role"; Expression = {$_.role.displayName}}); $rolesCount = ($roles | Measure-Object).Count; $controlResult.AddMessage("Total number of identities that have access to service connection: $($rolesCount)"); $controlResult.AddMessage([VerificationResult]::Verify,"Verify whether following identities have been provided with minimum RBAC access to service connection: ", $roles); $controlResult.SetStateData("List of identities having access to service connection: ", $roles); $controlResult.AdditionalInfo += "Total number of identities that have access to service connection: " + $rolesCount; } elseif(($this.ServiceEndpointsObj | Measure-Object).Count -eq 0) { $controlResult.AddMessage([VerificationResult]::Passed,"No role assignments found on service connection.") } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Unable to fetch role assignments.") $controlResult.LogException($_) } return $controlResult } hidden CheckActiveConnection() { try { $apiURL = "https://dev.azure.com/{0}/{1}/_apis/serviceendpoint/{2}/executionhistory?top=1&api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName), $($this.ResourceContext.ResourceGroupName), $($this.serviceendpointsobj.id); $serviceEndpointExecutionHistory = [WebRequestHelper]::InvokeGetWebRequest($apiURL); if (($serviceEndpointExecutionHistory | Measure-Object).Count -gt 0 -and ([Helpers]::CheckMember($serviceEndpointExecutionHistory[0],"data"))) { #if this job is still running then finishTime is not available. pass the control if ([Helpers]::CheckMember($serviceEndpointExecutionHistory[0].data, "finishTime")) { #Get the last known usage (job) timestamp of the service connection $svcLastRunDate = $serviceEndpointExecutionHistory[0].data.finishTime; #format date $formatLastRunTimeSpan = New-TimeSpan -Start (Get-Date $svcLastRunDate) # $inactiveLimit denotes the upper limit on number of days of inactivity before the svc conn is deemed inactive. if ($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings, "ServiceConnection.ServiceConnectionHistoryPeriodInDays") ) { $inactiveLimit = $this.ControlSettings.ServiceConnection.ServiceConnectionHistoryPeriodInDays if ($formatLastRunTimeSpan.Days -gt $inactiveLimit) { $this.SvcConnActivityDetail.isSvcConnActive = $false; $this.SvcConnActivityDetail.message = "Service connection has not been used in the last $inactiveLimit days."; } else { $this.SvcConnActivityDetail.isSvcConnActive = $true; $this.SvcConnActivityDetail.message = "Service connection has been used in the last $inactiveLimit days."; } } else { $this.SvcConnActivityDetail.isSvcConnActive = $false; $this.SvcConnActivityDetail.message = "Could not fetch the inactive days limit for service connection."; } if([ContextHelper]::PSVersion -gt 5) { $this.SvcConnActivityDetail.svcConnLastRunDate = [datetime]::Parse($svcLastRunDate.tostring("MM/dd/yyyy")); } else { $this.SvcConnActivityDetail.svcConnLastRunDate = [datetime]::Parse($svcLastRunDate); } } else { $this.SvcConnActivityDetail.isSvcConnActive = $true; $this.SvcConnActivityDetail.message = "Service connection was under use during the control scan."; } } else #service connection was created but never used. (Fail for now) { $this.SvcConnActivityDetail.isSvcConnActive = $false; $this.SvcConnActivityDetail.message = "Service connection has never been used."; } } catch { $this.SvcConnActivityDetail.message = "Could not fetch the service connection details."; $this.SvcConnActivityDetail.errorObject = $_ } $this.SvcConnActivityDetail.isComputed = $true } hidden [ControlResult] CheckBroaderGroupAccess ([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { if ($null -eq $this.serviceEndPointIdentity) { $apiURL = "https://dev.azure.com/{0}/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/{1}_{2}" -f $($this.OrganizationContext.OrganizationName), $($this.ProjectId), $($this.ServiceEndpointsObj.id); $this.serviceEndPointIdentity = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); } $restrictedGroups = @(); $restrictedBroaderGroups = @{} $restrictedBroaderGroupsForSvcConn = $this.ControlSettings.ServiceConnection.RestrictedBroaderGroupsForSvcConn; #Converting controlsettings broader groups into a hashtable. $restrictedBroaderGroupsForSvcConn.psobject.properties | foreach { $restrictedBroaderGroups[$_.Name] = $_.Value } if (($this.serviceEndPointIdentity.Count -gt 0) -and [Helpers]::CheckMember($this.serviceEndPointIdentity, "identity")) { # match all the identities added on service connection with defined restricted list $roleAssignments = @(); $roleAssignmentsToCheck = $this.serviceEndPointIdentity if ($this.checkInheritedPermissionsPerSvcConn -eq $false) { $roleAssignmentsToCheck = $this.serviceEndPointIdentity | where-object { $_.access -ne "inherited" } } $roleAssignments = @($roleAssignmentsToCheck | Select-Object -Property @{Name="Name"; Expression = {$_.identity.displayName}},@{Name="Id"; Expression = {$_.identity.id}},@{Name="AccessDisplayName"; Expression = {$_.accessDisplayName}},@{Name="Role"; Expression = {$_.role.displayName}}); #Checking where broader groups have excessive permission on service connection $restrictedGroups += @($roleAssignments | Where-Object { $restrictedBroaderGroups.keys -contains $_.Name.split('\')[-1] -and ($_.Role -in $restrictedBroaderGroups[$_.Name.split('\')[-1]])}) if ($this.ControlSettings.CheckForBroadGroupMemberCount -and $restrictedGroups.Count -gt 0) { $broaderGroupsWithExcessiveMembers = @([ControlHelper]::FilterBroadGroupMembers($restrictedGroups, $true)) $restrictedGroups = @($restrictedGroups | Where-Object {$broaderGroupsWithExcessiveMembers -contains $_.Name}) } $restrictedGroupsCount = $restrictedGroups.Count # fail the control if restricted group found on service connection if ($restrictedGroupsCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed, "Count of broader groups that have excessive permissions on service connection: $($restrictedGroupsCount)") $backupDataObject = $restrictedGroups | Select @{l = 'Group'; e = { $_.Name} },@{l = 'Id'; e = { $_.Id} }, @{l = 'Role'; e = { $_.Role } },@{l = 'AccessDisplayName'; e = { $_.AccessDisplayName } } $formattedGroupsData = $restrictedGroups | Select @{l = 'Group'; e = { $_.Name} }, @{l = 'Role'; e = { $_.Role } },@{l = 'AccessDisplayName'; e = { $_.AccessDisplayName } } $formattedGroupsTable = ($formattedGroupsData | FT -AutoSize | Out-String -width 512) $controlResult.AddMessage("`nList of groups: ", $formattedGroupsTable) $controlResult.SetStateData("List of groups: ", $formattedGroupsTable) $controlResult.AdditionalInfo += "Count of broader groups that have excessive permissions on service connection: $($restrictedGroupsCount)"; if ($this.ControlFixBackupRequired -or $this.BaselineConfigurationRequired) { #Data object that will be required to fix the control $controlResult.BackupControlState = $backupDataObject; } if($this.BaselineConfigurationRequired){ $controlResult.AddMessage([Constants]::BaselineConfigurationMsg -f $this.ResourceContext.ResourceName); $this.CheckBroaderGroupAccessAutomatedFix($controlResult); } $restrictedGroupsAccess = $restrictedGroups | ForEach-Object { $_.Name + ': ' + $_.Role } $controlResult.AdditionalInfoInCSV = $restrictedGroupsAccess -join '; ' } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have excessive permissions on service connection."); $controlResult.AdditionalInfoInCSV = "NA"; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No broader groups have excessive permissions on service connection."); $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 'broad' which should not have excessive permissions: `n$($displayObj | FT | out-string -width 512)`n"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Unable to fetch service connections details. Please verify from portal that you are not granting global security groups access to service connections"); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckBroaderGroupAccessAutomatedFix ([ControlResult] $controlResult) { try { $RawDataObjForControlFix = @(); if($this.BaselineConfigurationRequired){ $RawDataObjForControlFix = $controlResult.BackupControlState; } else{ $RawDataObjForControlFix = ([ControlHelper]::ControlFixBackup | where-object {$_.ResourceId -eq $this.ResourceId}).DataObject } $body = "[" if (-not $this.UndoFix) { foreach ($identity in $RawDataObjForControlFix) { if ($body.length -gt 1) {$body += ","} $body += @" { "userId": "$($identity.id)", "roleName": "Reader", "uniqueName": "$($identity.accessDisplayName)" } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName NewRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.group}}, @{Name="OldRole"; Expression={$_.Role}},@{Name="NewRole"; Expression={$_.NewRole}}) } else { foreach ($identity in $RawDataObjForControlFix) { if ($body.length -gt 1) {$body += ","} $body += @" { "userId": "$($identity.id)", "roleName": "$($identity.role)", "uniqueName": "$($identity.accessDisplayName)" } "@; } $RawDataObjForControlFix | Add-Member -NotePropertyName OldRole -NotePropertyValue "Reader" $RawDataObjForControlFix = @($RawDataObjForControlFix | Select-Object @{Name="DisplayName"; Expression={$_.group}}, @{Name="OldRole"; Expression={$_.OldRole}},@{Name="NewRole"; Expression={$_.Role}}) } $body += "]" #Put request $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/securityroles/scopes/distributedtask.serviceendpointrole/roleassignments/resources/$($this.ProjectId)_$($this.ServiceEndpointsObj.id)?api-version=5.0-preview.1"; $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] CheckRestricedCloudEnvironment ([ControlResult] $controlResult) { $disallowedEnvironments = @() if ($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings, "Organization.DisallowedEnvironments") ) { $disallowedEnvironments = $this.ControlSettings.Organization.DisallowedEnvironments } if($disallowedEnvironments.Length -ne 0) { $controlResult.AddMessage( "List of disallowed cloud environments.", $disallowedEnvironments); if ((-not [Helpers]::CheckMember($this.ServiceEndpointsObj, "data")) -or [string]::IsNullOrEmpty($this.ServiceEndpointsObj.data) -or (-not[Helpers]::CheckMember($this.ServiceEndpointsObj.data, "environment"))) { $controlResult.AddMessage([VerificationResult]::Passed, "Unable to determine the cloud environment for the service connection."); } else { $serviceConnectionEnvironment = $this.ServiceEndpointsObj.data.environment #check if the current environment is in list of restricted environments if ($disallowedEnvironments -contains $serviceConnectionEnvironment) { $controlResult.AddMessage([VerificationResult]::Failed, "Service connection is connected to restricted cloud environment: $serviceConnectionEnvironment"); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Service connection is not connected to restricted cloud environments."); } } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No restricted cloud environments were configured in control settings."); } return $controlResult; } hidden [ControlResult] CheckBranchControlForSvcConn ([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed $checkObj = $this.GetResourceApprovalCheck() try{ #check if resources is accessible even to a single pipeline $isRsrcAccessibleToAnyPipeline = $false; if ($null -eq $this.pipelinePermission) { $apiURL = "https://dev.azure.com/{0}/{1}/_apis/pipelines/pipelinePermissions/endpoint/{2}?api-version=6.1-preview.1" -f $($this.OrganizationContext.OrganizationName),$($this.ProjectId),$($this.ServiceEndpointsObj.id) ; $this.pipelinePermission = [WebRequestHelper]::InvokeGetWebRequest($apiURL); } if([Helpers]::CheckMember($this.pipelinePermission,"allPipelines") -and $this.pipelinePermission.allPipelines.authorized){ $isRsrcAccessibleToAnyPipeline = $true; } if([Helpers]::CheckMember($this.pipelinePermission[0],"pipelines") -and $this.pipelinePermission[0].pipelines.Count -gt 0){ $isRsrcAccessibleToAnyPipeline = $true; } #if resource is not accessible to any YAML pipeline, there is no need to add any branch control, hence passing the control if($isRsrcAccessibleToAnyPipeline -eq $false){ $controlResult.AddMessage([VerificationResult]::Passed, "Service connection is not accessible to any YAML pipelines. Hence, branch control is not required."); return $controlResult; } if(!$checkObj.ApprovalCheckObj){ $controlResult.AddMessage([VerificationResult]::Failed, "No approvals and checks have been defined for the service connection."); $controlResult.AdditionalInfo = "No approvals and checks have been defined for the service connection." $controlResult.AdditionalInfoInCsv = "No approvals and checks have been defined for the service connection." } else{ #we need to check only for two kinds of approvals and checks: manual approvals and branch controls, hence filtering these two out from the list $branchControl = @() $approvalControl = @() try{ $approvalAndChecks = @($checkObj.ApprovalCheckObj | Where-Object {$_.PSObject.Properties.Name -contains "settings"}) $branchControl = @($approvalAndChecks.settings | Where-Object {$_.PSObject.Properties.Name -contains "displayName" -and $_.displayName -eq "Branch Control"}) $approvalControl = @($approvalAndChecks | Where-Object {$_.PSObject.Properties.Name -contains "type" -and $_.type.name -eq "Approval"}) } catch{ $branchControl = @() } if($branchControl.Count -eq 0){ #if branch control is not enabled, but manual approvers are added pass this control if($approvalControl.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Passed, "Branch control has not been defined for the service connection. However, manual approvals have been added to the service connection."); $approvers = $approvalControl.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 service connection $($approvers)."; } else{ $controlResult.AddMessage([VerificationResult]::Failed, "Branch control has not been defined for the service connection."); $controlResult.AdditionalInfo = "Branch control has not been defined for the service connection." } } else{ $branches = ($branchControl.inputs.allowedBranches).Split(","); $branchesWithNoProtectionCheck = @($branchControl.inputs | where-object {$_.ensureProtectionOfBranch -eq $false}) if("*" -in $branches){ $controlResult.AddMessage([VerificationResult]::Failed, "All branches have been given access to the service connection."); $controlResult.AdditionalInfo = "All branches have been given access to the service connection." $controlResult.AdditionalInfoInCsv = "All branches have been given access to the service connection." } elseif ($branchesWithNoProtectionCheck.Count -gt 0) { #check if branch protection is enabled on all the found branches depending upon the org policy if($this.ControlSettings.ServiceConnection.CheckForBranchProtection){ $controlResult.AddMessage([VerificationResult]::Failed, "Access to the service connection has not been granted to all branches. However, verification of branch protection has not been enabled for some branches."); $branchesWithNoProtectionCheck = @(($branchesWithNoProtectionCheck.allowedBranches).Split(",")); $controlResult.AddMessage("List of branches granted access to the service connection without verification of branch protection: ") $controlResult.AddMessage("$($branchesWithNoProtectionCheck | FT | Out-String)") $branchesWithProtection = @($branches | where {$branchesWithNoProtectionCheck -notcontains $_}) if($branchesWithProtection.Count -gt 0){ $controlResult.AddMessage("List of branches granted access to the service connection with verification of branch protection: "); $controlResult.AddMessage("$($branchesWithProtection | FT | Out-String)"); } $controlResult.AdditionalInfo = "List of branches granted access to the service connection without verification of branch protection: $($branchesWithNoProtectionCheck)" } else{ $controlResult.AddMessage([VerificationResult]::Passed, "Access to the service connection has not been granted to all branches."); $controlResult.AddMessage("List of branches granted access to the service connection: "); $controlResult.AddMessage("$($branches | FT | Out-String)"); } } else{ $controlResult.AddMessage([VerificationResult]::Passed, "Access to the service connection has not been granted to all branches. Verification of branch protection has been enabled for all allowed branches."); $controlResult.AddMessage("List of branches granted access to the service connection: "); $controlResult.AddMessage("$($branches | FT | Out-String)"); } } } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch service connection details."); } return $controlResult; } hidden [ControlResult] CheckBroaderGroupApproversOnSvcConn ([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed $checkObj = $this.GetResourceApprovalCheck() try{ $restrictedGroups = @(); $restrictedBroaderGroupsForSerConn = $this.ControlSettings.ServiceConnection.RestrictedBroaderGroupsForApprovers; if(!$checkObj.ApprovalCheckObj){ $controlResult.AddMessage([VerificationResult]::Passed, "No approvals and checks have been defined for the service connection."); $controlResult.AdditionalInfo = "No approvals and checks have been defined for the service connection." } else { #we need to check for manual approvals and checks $approvalControl = @() try{ $approvalAndChecks = @($checkObj.ApprovalCheckObj | Where-Object {$_.PSObject.Properties.Name -contains "settings"}) $approvalControl = @($approvalAndChecks | Where-Object {$_.PSObject.Properties.Name -contains "type" -and $_.type.name -eq "Approval"}) } catch{ $approvalControl = @() } if($approvalControl.Count -gt 0) { $approvers = $approvalControl.settings.approvers | Select @{n='Approver name';e={$_.displayName}},@{n='Approver id';e = {$_.uniqueName}} $formattedApproversTable = ($approvers| FT -AutoSize | Out-String -width 512) # match all the identities added on service connection with defined restricted list $restrictedGroups = $approvalControl.settings.approvers | Where-Object { $restrictedBroaderGroupsForSerConn -contains $_.displayName.split('\')[-1] } | select displayName # fail the control if restricted group found on service connection if($restrictedGroups) { $controlResult.AddMessage([VerificationResult]::Failed,"Broader groups have been added as approvers on service connection."); $controlResult.AddMessage("Count of broader groups that have been added as approvers to service connection: ", @($restrictedGroups).Count) $controlResult.AddMessage("List of broader groups that have been added as approvers to service connection: ",$restrictedGroups) $controlResult.SetStateData("Broader groups have been added as approvers to service connection",$restrictedGroups) $controlResult.AdditionalInfo += "Count of broader groups that have been added as approvers to service connection: " + @($restrictedGroups).Count; $controlResult.AdditionalInfo += "List of broader groups added as approvers: "+ @($restrictedGroups) } else{ $controlResult.AddMessage([VerificationResult]::Passed,"No broader groups have been added as approvers to service connection."); $controlResult.AddMessage("`nList of approvers : `n$formattedApproversTable"); $controlResult.AdditionalInfo += "List of approvers on service connection $($approvers)."; } } else { $controlResult.AddMessage([VerificationResult]::Passed,"No broader groups have been added as approvers to service connection."); } } $displayObj = $restrictedBroaderGroupsForSerConn | Select-Object @{Name = "Broader Group"; Expression = {$_}} $controlResult.AddMessage("`nNote:`nThe following groups are considered 'broader' groups which should not be added as approvers: `n$($displayObj | FT | out-string -width 512)`n"); $restrictedGroups = $null; $restrictedBroaderGroupsForSerConn = $null; } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch service connection details."); } return $controlResult; } hidden [ControlResult] CheckTemplateBranchForSvcConn ([ControlResult] $controlResult) { try{ $checkObj = $this.GetResourceApprovalCheck() if(!$checkObj.ApprovalCheckObj){ $controlResult.AddMessage([VerificationResult]::Passed, "No approvals and checks have been defined for the variable group."); $controlResult.AdditionalInfo = "No approvals and checks have been defined for the variable group." } else{ $yamlTemplateControl = @() try{ $yamlTemplateControl = @($checkObj.ApprovalCheckObj | Where-Object {$_.PSObject.Properties.Name -contains "settings"}) $yamlTemplateControl = @($yamlTemplateControl.settings | Where-Object {$_.PSObject.Properties.Name -contains "extendsChecks"}) } catch{ $yamlTemplateControl = @() } if($yamlTemplateControl.Count -gt 0){ $yamlChecks = $yamlTemplateControl.extendsChecks $unProtectedBranches = @() #for branches with no branch policy $protectedBranches = @() #for branches with branch policy $unknownBranches = @() #for branches from external sources $yamlChecks | foreach { $yamlCheck = $_ #skip for any external source repo objects if($yamlCheck.repositoryType -ne 'git'){ $unknownBranches += (@{branch = ($yamlCheck.repositoryRef);repository = ($yamlCheck.repositoryName)}) return; } #repository name can be in two formats: "project/repo" OR for current project just "repo" if($yamlCheck.repositoryName -like "*/*"){ $project = ($yamlCheck.repositoryName -split "/")[0] $repository = ($yamlCheck.repositoryName -split "/")[1] } else{ $project = $this.ResourceContext.ResourceGroupName $repository = $yamlCheck.repositoryName } $branch = $yamlCheck.repositoryRef #policy API accepts only repo ID. Need to extract repo ID beforehand. $url = "https://dev.azure.com/{0}/{1}/_apis/git/repositories/{2}?api-version=6.0" -f $this.OrganizationContext.OrganizationName,$project,$repository $repoId = $null; try{ $response = @([WebRequestHelper]::InvokeGetWebRequest($url)) $repoId = $response.id } catch{ return; } $url = "https://dev.azure.com/{0}/{1}/_apis/git/policy/configurations?repositoryId={2}&refName={3}&api-version=5.0-preview.1" -f $this.OrganizationContext.OrganizationName,$project,$repoId,$branch $policyConfigResponse = @([WebRequestHelper]::InvokeGetWebRequest($url)) if([Helpers]::CheckMember($policyConfigResponse[0],"id")){ $branchPolicy = @($policyConfigResponse | Where-Object {$_.isEnabled -and $_.isBlocking}) #policyConfigResponse also contains repository policies, we need to filter out just branch policies $branchPolicy = @($branchPolicy | Where-Object {[Helpers]::CheckMember($_.settings.scope[0],"refName")}) if($branchPolicy.Count -gt 0) { $protectedBranches += (@{branch = $branch;repository = ($project+"/"+$repository)}) } else{ $unProtectedBranches += (@{branch = $branch;repository = ($project+"/"+$repository)}) } } else{ $unProtectedBranches += (@{branch = $branch;repository = ($project+"/"+$repository)}) } } #if branches with no branch policy is found, fail the control if($unProtectedBranches.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Failed, "Required template on the service connection extends from unprotected branches."); $unProtectedBranches =$unProtectedBranches | Select @{l="Repository";e={$_.repository}}, @{l="Branch";e={$_.branch}} $formattedGroupsTable = ($unProtectedBranches | FT -AutoSize | Out-String -width 512) $controlResult.AddMessage("`nList of unprotected branches: ", $formattedGroupsTable) $controlResult.SetStateData("List of unprotected branches: ", $formattedGroupsTable) } #if branches from external sources are found, control needs to be evaluated manually elseif($unknownBranches.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Manual, "Required template on the service connection extends from external sources."); $unknownBranches =$unknownBranches | Select @{l="Repository";e={$_.repository}}, @{l="Branch";e={$_.branch}} $formattedGroupsTable = ($unknownBranches | FT -AutoSize | Out-String -width 512) $controlResult.AddMessage("`nList of branches from external sources: ", $formattedGroupsTable) $controlResult.SetStateData("List of branches from external sources: ", $formattedGroupsTable) } #if all branches are protected, pass the control elseif($protectedBranches.Count -gt 0){ $controlResult.AddMessage([VerificationResult]::Passed, "Required template on the service connection extends from protected branches."); } else{ $controlResult.AddMessage([VerificationResult]::Manual, "Branch policies on required template on the service connection could not be determined."); } if($protectedBranches.Count -gt 0){ $protectedBranches =$protectedBranches | Select @{l="Repository";e={$_.repository}}, @{l="Branch";e={$_.branch}} $formattedGroupsTable = ($protectedBranches | FT -AutoSize | Out-String -width 512) $controlResult.AddMessage("`nList of protected branches: ", $formattedGroupsTable) $controlResult.SetStateData("List of protected branches: ", $formattedGroupsTable) } } else{ $controlResult.AddMessage([VerificationResult]::Passed, "No required template has been defined for the service connection."); } } } catch{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch service connection details."); } return $controlResult; } } # SIG # Begin signature block # MIIoKgYJKoZIhvcNAQcCoIIoGzCCKBcCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCbhsBOOyLjMRY9 # 1J4YSmAaPHYoCb9cPekCmmnj7hl++aCCDXYwggX0MIID3KADAgECAhMzAAADrzBA # DkyjTQVBAAAAAAOvMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjMxMTE2MTkwOTAwWhcNMjQxMTE0MTkwOTAwWjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQDOS8s1ra6f0YGtg0OhEaQa/t3Q+q1MEHhWJhqQVuO5amYXQpy8MDPNoJYk+FWA # hePP5LxwcSge5aen+f5Q6WNPd6EDxGzotvVpNi5ve0H97S3F7C/axDfKxyNh21MG # 0W8Sb0vxi/vorcLHOL9i+t2D6yvvDzLlEefUCbQV/zGCBjXGlYJcUj6RAzXyeNAN # xSpKXAGd7Fh+ocGHPPphcD9LQTOJgG7Y7aYztHqBLJiQQ4eAgZNU4ac6+8LnEGAL # go1ydC5BJEuJQjYKbNTy959HrKSu7LO3Ws0w8jw6pYdC1IMpdTkk2puTgY2PDNzB # tLM4evG7FYer3WX+8t1UMYNTAgMBAAGjggFzMIIBbzAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQURxxxNPIEPGSO8kqz+bgCAQWGXsEw # RQYDVR0RBD4wPKQ6MDgxHjAcBgNVBAsTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEW # MBQGA1UEBRMNMjMwMDEyKzUwMTgyNjAfBgNVHSMEGDAWgBRIbmTlUAXTgqoXNzci # tW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vd3d3Lm1pY3Jvc29mdC5j # b20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3JsMGEG # CCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDovL3d3dy5taWNyb3NvZnQu # Y29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3J0 # MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIBAISxFt/zR2frTFPB45Yd # mhZpB2nNJoOoi+qlgcTlnO4QwlYN1w/vYwbDy/oFJolD5r6FMJd0RGcgEM8q9TgQ # 2OC7gQEmhweVJ7yuKJlQBH7P7Pg5RiqgV3cSonJ+OM4kFHbP3gPLiyzssSQdRuPY # 1mIWoGg9i7Y4ZC8ST7WhpSyc0pns2XsUe1XsIjaUcGu7zd7gg97eCUiLRdVklPmp # XobH9CEAWakRUGNICYN2AgjhRTC4j3KJfqMkU04R6Toyh4/Toswm1uoDcGr5laYn # TfcX3u5WnJqJLhuPe8Uj9kGAOcyo0O1mNwDa+LhFEzB6CB32+wfJMumfr6degvLT # e8x55urQLeTjimBQgS49BSUkhFN7ois3cZyNpnrMca5AZaC7pLI72vuqSsSlLalG # OcZmPHZGYJqZ0BacN274OZ80Q8B11iNokns9Od348bMb5Z4fihxaBWebl8kWEi2O # PvQImOAeq3nt7UWJBzJYLAGEpfasaA3ZQgIcEXdD+uwo6ymMzDY6UamFOfYqYWXk # ntxDGu7ngD2ugKUuccYKJJRiiz+LAUcj90BVcSHRLQop9N8zoALr/1sJuwPrVAtx # HNEgSW+AKBqIxYWM4Ev32l6agSUAezLMbq5f3d8x9qzT031jMDT+sUAoCw0M5wVt # CUQcqINPuYjbS1WgJyZIiEkBMIIHejCCBWKgAwIBAgIKYQ6Q0gAAAAAAAzANBgkq # hkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x # EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv # bjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 # IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEwOTA5WjB+MQswCQYDVQQG # EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG # A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYDVQQDEx9NaWNyb3NvZnQg # Q29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC # CgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+laUKq4BjgaBEm6f8MMHt03 # a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc6Whe0t+bU7IKLMOv2akr # rnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4Ddato88tt8zpcoRb0Rrrg # OGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+lD3v++MrWhAfTVYoonpy # 4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nkkDstrjNYxbc+/jLTswM9 # sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6A4aN91/w0FK/jJSHvMAh # dCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmdX4jiJV3TIUs+UsS1Vz8k # A/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL5zmhD+kjSbwYuER8ReTB # w3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zdsGbiwZeBe+3W7UvnSSmn # Eyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3T8HhhUSJxAlMxdSlQy90 # lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS4NaIjAsCAwEAAaOCAe0w # ggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRIbmTlUAXTgqoXNzcitW2o # ynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYD # VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBDuRQFTuHqp8cx0SOJNDBa # BgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2Ny # bC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3JsMF4GCCsG # AQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3dy5taWNyb3NvZnQuY29t # L3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3J0MIGfBgNV # HSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEFBQcCARYzaHR0cDovL3d3 # dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1hcnljcHMuaHRtMEAGCCsG # AQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkAYwB5AF8AcwB0AGEAdABl # AG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn8oalmOBUeRou09h0ZyKb # C5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7v0epo/Np22O/IjWll11l # hJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0bpdS1HXeUOeLpZMlEPXh6 # I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/KmtYSWMfCWluWpiW5IP0 # wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvyCInWH8MyGOLwxS3OW560 # STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBpmLJZiWhub6e3dMNABQam # ASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJihsMdYzaXht/a8/jyFqGa # J+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYbBL7fQccOKO7eZS/sl/ah # XJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbSoqKfenoi+kiVH6v7RyOA # 9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sLgOppO6/8MO0ETI7f33Vt # Y5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtXcVZOSEXAQsmbdlsKgEhr # /Xmfwb1tbWrJUnMTDXpQzTGCGgowghoGAgEBMIGVMH4xCzAJBgNVBAYTAlVTMRMw # EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN # aWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNp # Z25pbmcgUENBIDIwMTECEzMAAAOvMEAOTKNNBUEAAAAAA68wDQYJYIZIAWUDBAIB # BQCgga4wGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEO # MAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIFNi4+GBmA8cPBbd7mfKJgFp # NhlnFAIHx/NjxtrBou9gMEIGCisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8A # cwBvAGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEB # BQAEggEAdN5M5kJjn9vfzEEhjFB8DxMhSSDXqhBxY8nUWbkNRdkC5Znb+iYha8ug # YzdH2JpIhughOUJ1YPVzSnAl4op3z/Qt6/Zf5RZAthNIGXeS0A5hNXXV1RZ1AdBE # HxBihtb3I9EYGzUwBWHDeEcFeEeqw4vp7CNWyy8zOjLTd0uuAKPvtYxG8rJFLmPT # 8ba8XO3qel1JojSFFU7/LyWGynJhQPMsK+WUCF2soFesCo68ndwnplABsHtvEubL # 2zo3Vfi4jegmiAikzlTFpenk6soTk1VEaNnbRsktaCfXwhv3428UiFFW0mefrGVo # gbVokr5aE9Y3sDbaMB8qIdvPLIdttqGCF5QwgheQBgorBgEEAYI3AwMBMYIXgDCC # F3wGCSqGSIb3DQEHAqCCF20wghdpAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFSBgsq # hkiG9w0BCRABBKCCAUEEggE9MIIBOQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFl # AwQCAQUABCBqGNIgpgvUSBFuup3wYejULatruhn2qp9UAgSzki6nEQIGZeeoEl88 # GBMyMDI0MDMxMjA2NTU1MS45ODJaMASAAgH0oIHRpIHOMIHLMQswCQYDVQQGEwJV # UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE # ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1l # cmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxkIFRTUyBFU046QTQwMC0w # NUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2Wg # ghHqMIIHIDCCBQigAwIBAgITMwAAAezgK6SC0JFSgAABAAAB7DANBgkqhkiG9w0B # AQsFADB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE # BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYD # VQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAeFw0yMzEyMDYxODQ1 # MzhaFw0yNTAzMDUxODQ1MzhaMIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2Fz # aGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENv # cnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25z # MScwJQYDVQQLEx5uU2hpZWxkIFRTUyBFU046QTQwMC0wNUUwLUQ5NDcxJTAjBgNV # BAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2UwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQCwR/RuCTbgxUWVm/Vdul22uwdEZm0IoAFs6oIr39VK # /ItP80cn+8TmtP67iabB4DmAKJ9GH6dJGhEPJpY4vTKRSOwrRNxVIKoPPeUF3f4V # yHEco/u1QUadlwD132NuZCxbnh6Mi2lLG7pDvszZqMG7S3MCi2bk2nvtGKdeAIL+ # H77gL4r01TSWb7rsE2Jb1P/N6Y/W1CqDi1/Ib3/zRqWXt4zxvdIGcPjS4ZKyQEF3 # SEZAq4XIjiyowPHaqNbZxdf2kWO/ajdfTU85t934CXAinb0o+uQ9KtaKNLVVcNf5 # QpS4f6/MsXOvIFuCYMRdKDjpmvowAeL+1j27bCxCBpDQHrWkfPzZp/X+bt9C7E5h # PP6HVRoqBYR7u1gUf5GEq+5r1HA0jajn0Q6OvfYckE0HdOv6KWa+sAmJG7PDvTZa # e77homzx6IPqggVpNZuCk79SfVmnKu9F58UAnU58TqDHEzGsQnMUQKstS3zjn6SU # 0NLEFNCetluaKkqWDRVLEWbu329IEh3tqXPXfy6Rh/wCbwe9SCJIoqtBexBrPyQY # A2Xaz1fK9ysTsx0kA9V1JwVV44Ia9c+MwtAR6sqKdAgRo/bs/Xu8gua8LDe6KWyu # 974e9mGW7ZO8narDFrAT1EXGHDueygSKvv2K7wB8lAgMGJj73CQvr+jqoWwx6Xdy # eQIDAQABo4IBSTCCAUUwHQYDVR0OBBYEFPRa0Edk/iv1whYQsV8UgEf4TIWGMB8G # A1UdIwQYMBaAFJ+nFV0AXmJdg/Tl0mWnG1M1GelyMF8GA1UdHwRYMFYwVKBSoFCG # Tmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUy # MFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNybDBsBggrBgEFBQcBAQRgMF4w # XAYIKwYBBQUHMAKGUGh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2Vy # dHMvTWljcm9zb2Z0JTIwVGltZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3J0MAwG # A1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwDgYDVR0PAQH/BAQD # AgeAMA0GCSqGSIb3DQEBCwUAA4ICAQCSvMSkMSrvjlDPag8ARb0OFrAQtSLMDpN0 # UY3FjvPhwGKDrrixmnuMfjrmVjRq1u8IhkDvGF/bffbFTr+IAnDSeg8TB9zfG/4y # bknuopklbeGjbt7MLxpfholCERyEc20PMZKJz9SvzfuO1n5xrrLOL8m0nmv5kBcv # +y1AXJ5QcLicmhe2Ip3/D67Ed6oPqQI03mDjYaS1NQhBNtu57wPKXZ1EoNToBk8b # A6839w119b+a9WToqIskdRGoP5xjDIv+mc0vBHhZGkJVvfIhm4Ap8zptC7xVAly0 # jeOv5dUGMCYgZjvoTmgd45bqAwundmPlGur7eleWYedLQf7s3L5+qfaY/xEh/9uo # 17SnM/gHVSGAzvnreGhOrB2LtdKoVSe5LbYpihXctDe76iYtL+mhxXPEpzda3bJl # hPTOQ3KOEZApVERBo5yltWjPCWlXxyCpl5jj9nY0nfd071bemnou8A3rUZrdgKIa # utsH7SHOiOebZGqNu+622vJta3eAYsCAaxAcB9BiJPla7Xad9qrTYdT45VlCYTtB # SY4oVRsedSADv99jv/iYIAGy1bCytua0o/Qqv9erKmzQCTVMXaDc25DTLcMGJrRu # a3K0xivdtnoBexzVJr6yXqM+Ba2whIVRvGcriBkKX0FJFeW7r29XX+k0e4DnG6iB # HKQjec6VNzCCB3EwggVZoAMCAQICEzMAAAAVxedrngKbSZkAAAAAABUwDQYJKoZI # hvcNAQELBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw # DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x # MjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAy # MDEwMB4XDTIxMDkzMDE4MjIyNVoXDTMwMDkzMDE4MzIyNVowfDELMAkGA1UEBhMC # VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV # BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRp # bWUtU3RhbXAgUENBIDIwMTAwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC # AQDk4aZM57RyIQt5osvXJHm9DtWC0/3unAcH0qlsTnXIyjVX9gF/bErg4r25Phdg # M/9cT8dm95VTcVrifkpa/rg2Z4VGIwy1jRPPdzLAEBjoYH1qUoNEt6aORmsHFPPF # dvWGUNzBRMhxXFExN6AKOG6N7dcP2CZTfDlhAnrEqv1yaa8dq6z2Nr41JmTamDu6 # GnszrYBbfowQHJ1S/rboYiXcag/PXfT+jlPP1uyFVk3v3byNpOORj7I5LFGc6XBp # Dco2LXCOMcg1KL3jtIckw+DJj361VI/c+gVVmG1oO5pGve2krnopN6zL64NF50Zu # yjLVwIYwXE8s4mKyzbnijYjklqwBSru+cakXW2dg3viSkR4dPf0gz3N9QZpGdc3E # XzTdEonW/aUgfX782Z5F37ZyL9t9X4C626p+Nuw2TPYrbqgSUei/BQOj0XOmTTd0 # lBw0gg/wEPK3Rxjtp+iZfD9M269ewvPV2HM9Q07BMzlMjgK8QmguEOqEUUbi0b1q # GFphAXPKZ6Je1yh2AuIzGHLXpyDwwvoSCtdjbwzJNmSLW6CmgyFdXzB0kZSU2LlQ # +QuJYfM2BjUYhEfb3BvR/bLUHMVr9lxSUV0S2yW6r1AFemzFER1y7435UsSFF5PA # PBXbGjfHCBUYP3irRbb1Hode2o+eFnJpxq57t7c+auIurQIDAQABo4IB3TCCAdkw # EgYJKwYBBAGCNxUBBAUCAwEAATAjBgkrBgEEAYI3FQIEFgQUKqdS/mTEmr6CkTxG # NSnPEP8vBO4wHQYDVR0OBBYEFJ+nFV0AXmJdg/Tl0mWnG1M1GelyMFwGA1UdIARV # MFMwUQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWlj # cm9zb2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0bTATBgNVHSUEDDAK # BggrBgEFBQcDCDAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMC # AYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvX # zpoYxDBWBgNVHR8ETzBNMEugSaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20v # cGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYI # KwYBBQUHAQEETjBMMEoGCCsGAQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5j # b20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNydDANBgkqhkiG # 9w0BAQsFAAOCAgEAnVV9/Cqt4SwfZwExJFvhnnJL/Klv6lwUtj5OR2R4sQaTlz0x # M7U518JxNj/aZGx80HU5bbsPMeTCj/ts0aGUGCLu6WZnOlNN3Zi6th542DYunKmC # VgADsAW+iehp4LoJ7nvfam++Kctu2D9IdQHZGN5tggz1bSNU5HhTdSRXud2f8449 # xvNo32X2pFaq95W2KFUn0CS9QKC/GbYSEhFdPSfgQJY4rPf5KYnDvBewVIVCs/wM # nosZiefwC2qBwoEZQhlSdYo2wh3DYXMuLGt7bj8sCXgU6ZGyqVvfSaN0DLzskYDS # PeZKPmY7T7uG+jIa2Zb0j/aRAfbOxnT99kxybxCrdTDFNLB62FD+CljdQDzHVG2d # Y3RILLFORy3BFARxv2T5JL5zbcqOCb2zAVdJVGTZc9d/HltEAY5aGZFrDZ+kKNxn # GSgkujhLmm77IVRrakURR6nxt67I6IleT53S0Ex2tVdUCbFpAUR+fKFhbHP+Crvs # QWY9af3LwUFJfn6Tvsv4O+S3Fb+0zj6lMVGEvL8CwYKiexcdFYmNcP7ntdAoGokL # jzbaukz5m/8K6TT4JDVnK+ANuOaMmdbhIurwJ0I9JZTmdHRbatGePu1+oDEzfbzL # 6Xu/OHBE0ZDxyKs6ijoIYn/ZcGNTTY3ugm2lBRDBcQZqELQdVTNYs6FwZvKhggNN # MIICNQIBATCB+aGB0aSBzjCByzELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hp # bmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jw # b3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9uczEn # MCUGA1UECxMeblNoaWVsZCBUU1MgRVNOOkE0MDAtMDVFMC1EOTQ3MSUwIwYDVQQD # ExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQCO # HPtgVdz9EW0iPNL/BXqJoqVMf6CBgzCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w # IFBDQSAyMDEwMA0GCSqGSIb3DQEBCwUAAgUA6ZoPHjAiGA8yMDI0MDMxMTIzMTU0 # MloYDzIwMjQwMzEyMjMxNTQyWjB0MDoGCisGAQQBhFkKBAExLDAqMAoCBQDpmg8e # AgEAMAcCAQACAhRKMAcCAQACAhSuMAoCBQDpm2CeAgEAMDYGCisGAQQBhFkKBAIx # KDAmMAwGCisGAQQBhFkKAwKgCjAIAgEAAgMHoSChCjAIAgEAAgMBhqAwDQYJKoZI # hvcNAQELBQADggEBAJZal89qn3QXiPOdNuaPW6h+9UMujUJtMypB0jLCVuSn5nTr # 5wqoypKmF/iZEeTuuwvG0JVd7tkBLDqfEcRtwblomkHLXy/slJdvFxgdNJKo3h6O # 8A4Z71sU1X1c/dNvm9FTIDMQqZvAOzjAXAllStEtf6NP4iTXqcmery53TWYJK54f # 424BENq2/UsnKqoMXDOCMvF53heboOwc7LYqnI/MJfnZKmXcSWyebzHobB/7TaBc # 5kYHs9maT/pKtJjVgl3P/jiyQ47ZomIHH8GMHreUIglnD4FkMNJXxjkz9lHCUqld # OWRRGY6lBLykFUmMWZjkEgm8V8QQtxHj1up8LxMxggQNMIIECQIBATCBkzB8MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNy # b3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMAITMwAAAezgK6SC0JFSgAABAAAB7DAN # BglghkgBZQMEAgEFAKCCAUowGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8G # CSqGSIb3DQEJBDEiBCAgMZlBCJWzTp9jdsvKckpgY/z8E2MCvyyNZUO3fjLbyTCB # +gYLKoZIhvcNAQkQAi8xgeowgecwgeQwgb0EICcJ5vVqfTfIhx21QBBbKyo/xciQ # IXaoMWULejAE1QqDMIGYMIGApH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldh # c2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBD # b3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIw # MTACEzMAAAHs4CukgtCRUoAAAQAAAewwIgQg8Mwj3lG89YF+2gepALnhaO6neN+A # qEokef/OW28qQ9AwDQYJKoZIhvcNAQELBQAEggIAqB60zWWPrkalALryDkZKETz+ # oE5a2wP3aAuN5In+xWfgKheTr3sb0q73Ghcz88XSp6WSgnkZ0n7ZLW4tbX7bCCkS # JuvtMp3ATDyBgGFrEoMBrF27rqPV0evfkyYzdg/b788Z/wtcOAGvlFUae/mjBKqK # tJDrq9nOe7/sFo3ewRA+RSnjDGZW95AENjgq/BW0eNEkjYDFXyPnuCEuCwep37e5 # biU8Kg6mELcLhrGNMBzivwHaWSxZMlRZ+/xZHGW/pE1gH0E8U4HSr+NikmAShGJD # /cp1c2X4w+QQv8PtQ/IqSISIqsbbbW0rFmlS1liLqnwbcIiFdomARl4gKUE5Du1U # Si3h8Cq9LBlR6ugCKZbSUXQ8Vrn1toJhyTx2JPWEaokEgVZ2XQps53BXyAc4nVjE # hpflCDNlrP7Kog7ASGOMTGqaqFsW6q8HpjdmhyjobyjCX4HZYzfsvPDKcl7qPWDJ # liwWNHmkPz3lfmP9opUBzN6tiTL6lwp4g4rfV1mcorTN0n8hcHlTKoLekEYRQ4qx # /fuEu/ebUboovBmxyEFvEGdzPN1M0GGrvfXvykGWhnldMYUBIKThll4tr+U+8hfW # zufmNJIqrIEiuZvuoC6vZdtNaHuuGY8X9vvHdmpLAKcr3EkYMzJ1MsrPf12M2wmW # U75Y1rYn/thS5OHY6eQ= # SIG # End signature block |