Framework/Core/SVT/ADO/ADO.Organization.ps1
Set-StrictMode -Version Latest class Organization: ADOSVTBase { [PSObject] $ServiceEndPointsObj = $null [PSObject] $PipelineSettingsObj = $null [PSObject] $OrgPolicyObj = $null #TODO: testing below line hidden [string] $SecurityNamespaceId; Organization([string] $subscriptionId, [SVTResource] $svtResource): Base($subscriptionId,$svtResource) { $this.GetOrgPolicyObject() $this.GetPipelineSettingsObj() } GetOrgPolicyObject() { try { $uri ="https://dev.azure.com/{0}/_settings/organizationPolicy?__rt=fps&__ver=2" -f $($this.SubscriptionContext.SubscriptionName); $response = [WebRequestHelper]::InvokeGetWebRequest($uri); if($response -and [Helpers]::CheckMember($response.fps.dataProviders,"data") -and $response.fps.dataProviders.data.'ms.vss-admin-web.organization-policies-data-provider') { $this.OrgPolicyObj = $response.fps.dataProviders.data.'ms.vss-admin-web.organization-policies-data-provider'.policies } } catch # Added above new api to get User policy settings, old api is not returning. Fallback to old API in catch { $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/Contribution/dataProviders/query?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $orgUrl = "https://dev.azure.com/{0}" -f $($this.SubscriptionContext.SubscriptionName); $inputbody = "{'contributionIds':['ms.vss-org-web.collection-admin-policy-data-provider'],'context':{'properties':{'sourcePage':{'url':'$orgUrl/_settings/policy','routeId':'ms.vss-admin-web.collection-admin-hub-route','routeValues':{'adminPivot':'policy','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json $responseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); if([Helpers]::CheckMember($responseObj,"data") -and $responseObj.data.'ms.vss-org-web.collection-admin-policy-data-provider') { $this.OrgPolicyObj = $responseObj.data.'ms.vss-org-web.collection-admin-policy-data-provider'.policies } } } GetPipelineSettingsObj() { $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $orgUrl = "https://dev.azure.com/{0}" -f $($this.SubscriptionContext.SubscriptionName); #$inputbody = "{'contributionIds':['ms.vss-org-web.collection-admin-policy-data-provider'],'context':{'properties':{'sourcePage':{'url':'$orgUrl/_settings/policy','routeId':'ms.vss-admin-web.collection-admin-hub-route','routeValues':{'adminPivot':'policy','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json $inputbody = "{'contributionIds':['ms.vss-build-web.pipelines-org-settings-data-provider'],'dataProviderContext':{'properties':{'sourcePage':{'url':'$orgUrl/_settings/pipelinessettings','routeId':'ms.vss-admin-web.collection-admin-hub-route','routeValues':{'adminPivot':'pipelinessettings','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json $responseObj = $null try{ $responseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); } catch{ #Write-Host "Pipeline settings for the organization [$($this.SubscriptionContext.SubscriptionName)] can not be fetched." } if([Helpers]::CheckMember($responseObj,"dataProviders")) { try { if($responseObj.dataProviders.'ms.vss-build-web.pipelines-org-settings-data-provider') { $this.PipelineSettingsObj = $responseObj.dataProviders.'ms.vss-build-web.pipelines-org-settings-data-provider' } } catch { #Write-Host "Pipeline settings for the organization [$($this.SubscriptionContext.SubscriptionName)] can not be fetched." } } } hidden [ControlResult] CheckProCollSerAcc([ControlResult] $controlResult) { try { #api call to get PCSA descriptor which used to get PCSA members api call. $url = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $body = '{"contributionIds":["ms.vss-admin-web.org-admin-groups-data-provider"],"dataProviderContext":{"properties":{"sourcePage":{"url":"https://dev.azure.com/{0}/_settings/groups","routeId":"ms.vss-admin-web.collection-admin-hub-route","routeValues":{"adminPivot":"groups","controller":"ContributedPage","action":"Execute"}}}}}' $body = ($body.Replace("{0}", $this.SubscriptionContext.SubscriptionName)) | ConvertFrom-Json $response = [WebRequestHelper]::InvokePostWebRequest($url,$body); $accname = "Project Collection Service Accounts"; #Enterprise Service Accounts if ($response -and [Helpers]::CheckMember($response[0],"dataProviders") -and $response[0].dataProviders."ms.vss-admin-web.org-admin-groups-data-provider") { $prcollobj = $response.dataProviders."ms.vss-admin-web.org-admin-groups-data-provider".identities | where {$_.displayName -eq $accname} #$prcollobj = $responseObj | where {$_.displayName -eq $accname} if(($prcollobj | Measure-Object).Count -gt 0) { #pai call to get PCSA members $prmemberurl = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $inputbody = '{"contributionIds":["ms.vss-admin-web.org-admin-members-data-provider"],"dataProviderContext":{"properties":{"subjectDescriptor":"{0}","sourcePage":{"url":"https://dev.azure.com/{1}/_settings/groups?subjectDescriptor={0}","routeId":"ms.vss-admin-web.collection-admin-hub-route","routeValues":{"adminPivot":"groups","controller":"ContributedPage","action":"Execute"}}}}}' $inputbody = $inputbody.Replace("{0}",$prcollobj.descriptor) $inputbody = $inputbody.Replace("{1}",$this.SubscriptionContext.SubscriptionName) | ConvertFrom-Json $responsePrCollObj = [WebRequestHelper]::InvokePostWebRequest($prmemberurl,$inputbody); $responsePrCollData = $responsePrCollObj.dataProviders.'ms.vss-admin-web.org-admin-members-data-provider'.identities $memberCount = ($responsePrCollData | Measure-Object).Count if($memberCount -gt 0){ $responsePrCollData = $responsePrCollData | Select-Object displayName,mailAddress,subjectKind $stateData = @(); $stateData += $responsePrCollData $controlResult.AddMessage("Total number of Project Collection Service Accounts: $($memberCount)"); $controlResult.AdditionalInfo += "Total number of Project Collection Service Accounts: " + $memberCount; $controlResult.AddMessage([VerificationResult]::Verify, "Review the members of the group Project Collection Service Accounts: ", $stateData); $controlResult.SetStateData("Members of the Project Collection Service Accounts group: ", $stateData); } else { #count is 0 then there is no member in the prj coll ser acc group $controlResult.AddMessage([VerificationResult]::Passed, "Project Collection Service Accounts group does not have any member."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Project Collection Service Accounts group could not be fetched."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Project Collection Service Accounts group could not be fetched."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of groups in the organization."); } return $controlResult } hidden [ControlResult] CheckSCALTForAdminMembers([ControlResult] $controlResult) { try { if(($null -ne $this.ControlSettings) -and [Helpers]::CheckMember($this.ControlSettings, "Organization.GroupsToCheckForSCAltMembers")) { $adminGroupNames = $this.ControlSettings.Organization.GroupsToCheckForSCAltMembers; if (($adminGroupNames | Measure-Object).Count -gt 0) { #api call to get descriptor for organization groups. This will be used to fetch membership of individual groups later. $url = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $body = '{"contributionIds":["ms.vss-admin-web.org-admin-groups-data-provider"],"dataProviderContext":{"properties":{"sourcePage":{"url":"https://dev.azure.com/{0}/_settings/groups","routeId":"ms.vss-admin-web.collection-admin-hub-route","routeValues":{"adminPivot":"groups","controller":"ContributedPage","action":"Execute"}}}}}' $body = ($body.Replace("{0}", $this.SubscriptionContext.SubscriptionName)) | ConvertFrom-Json $response = [WebRequestHelper]::InvokePostWebRequest($url,$body); if ($response -and [Helpers]::CheckMember($response[0],"dataProviders") -and $response[0].dataProviders."ms.vss-admin-web.org-admin-groups-data-provider") { $adminGroups = @(); $adminGroups += $response.dataProviders."ms.vss-admin-web.org-admin-groups-data-provider".identities | where { $_.displayName -in $adminGroupNames } $PCSAGroup = $response.dataProviders."ms.vss-admin-web.org-admin-groups-data-provider".identities | where { $_.displayName -eq "Project Collection Service Accounts"} if(($adminGroups | Measure-Object).Count -gt 0) { #global variable to track admin members across all admin groups $allAdminMembers = @(); $allPCSAMembers = @(); for ($i = 0; $i -lt $adminGroups.Count; $i++) { # [AdministratorHelper]::AllPCAMembers is a static variable. Always needs to be initialized. At the end of each iteration, it will be populated with members of that particular admin group. [AdministratorHelper]::AllPCAMembers = @(); # Helper function to fetch flattened out list of group members. [AdministratorHelper]::FindPCAMembers($adminGroups[$i].descriptor, $this.SubscriptionContext.SubscriptionName) $groupMembers = @(); # Add the members of current group to this temp variable. $groupMembers += [AdministratorHelper]::AllPCAMembers # Create a custom object to append members of current group with the group name. Each of these custom object is added to the global variable $allAdminMembers for further analysis of SC-Alt detection. $groupMembers | ForEach-Object {$allAdminMembers += @( [PSCustomObject] @{ name = $_.displayName; mailAddress = $_.mailAddress; id = $_.originId; groupName = $adminGroups[$i].displayName } )} } if(($PCSAGroup | Measure-Object).Count -gt 0) { # [AdministratorHelper]::AllPCAMembers is a static variable. Needs to be reinitialized as it might contain group info from the previous for loop. [AdministratorHelper]::AllPCAMembers = @(); # Helper function to fetch flattened out list of group members. [AdministratorHelper]::FindPCAMembers($PCSAGroup.descriptor, $this.SubscriptionContext.SubscriptionName) $groupMembers = @(); # Add the members of current group to this temp variable. $groupMembers += [AdministratorHelper]::AllPCAMembers # Preparing the list of members of PCSA which needs to be subtracted from $allAdminMembers #USE IDENTITY ID $groupMembers | ForEach-Object {$allPCSAMembers += @( [PSCustomObject] @{ name = $_.displayName; mailAddress = $_.mailAddress; id = $_.originId; groupName = "Project Collection Administrators" } )} } #Removing PCSA members from PCA members using id. #TODO: HAVE ANOTHER CONTROL TO CHECK FOR PCA because some service accounts might be added directly as PCA and as well as part of PCSA. This new control will serve as a hygiene control. if(($allPCSAMembers | Measure-Object).Count -gt 0) { $allAdminMembers = $allAdminMembers | ? {$_.id -notin $allPCSAMembers.id} } # clearing cached value in [AdministratorHelper]::AllPCAMembers as it can be used in attestation later and might have incorrect group loaded. [AdministratorHelper]::AllPCAMembers = @(); # Filtering out distinct entries. A user might be added directly to the admin group or might be a member of a child group of the admin group. $allAdminMembers = $allAdminMembers| Sort-Object -Property id -Unique if(($allAdminMembers | Measure-Object).Count -gt 0) { if([Helpers]::CheckMember($this.ControlSettings, "AlernateAccountRegularExpressionForOrg")){ $matchToSCAlt = $this.ControlSettings.AlernateAccountRegularExpressionForOrg #currently SC-ALT regex is a singleton expression. In case we have multiple regex - we need to make the controlsetting entry as an array and accordingly loop the regex here. if (-not [string]::IsNullOrEmpty($matchToSCAlt)) { $nonSCMembers = @(); $nonSCMembers += $allAdminMembers | Where-Object { $_.mailAddress -notmatch $matchToSCAlt } $nonSCCount = ($nonSCMembers | Measure-Object).Count $SCMembers = @(); $SCMembers += $allAdminMembers | Where-Object { $_.mailAddress -match $matchToSCAlt } $SCCount = ($SCMembers | Measure-Object).Count if ($nonSCCount -gt 0) { $nonSCMembers = $nonSCMembers | Select-Object name,mailAddress,groupName $stateData = @(); $stateData += $nonSCMembers $controlResult.AddMessage([VerificationResult]::Failed, "`nTotal number of non SC-ALT accounts with admin privileges: $nonSCCount"); $controlResult.AddMessage("Review the non SC-ALT accounts with admin privileges: ", $stateData); $controlResult.SetStateData("List of non SC-ALT accounts with admin privileges: ", $stateData); $controlResult.AdditionalInfo += "Total number of non SC-ALT accounts with admin privileges: " + $nonSCCount; } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users have admin privileges with non SC-ALT accounts."); } if ($SCCount -gt 0) { $SCMembers = $SCMembers | Select-Object name,mailAddress,groupName $SCData = @(); $SCData += $SCMembers $controlResult.AddMessage("`nTotal number of SC-ALT accounts with admin privileges: $SCCount"); $controlResult.AdditionalInfo += "Total number of SC-ALT accounts with admin privileges: " + $SCCount; $controlResult.AddMessage("SC-ALT accounts with admin privileges: ", $SCData); } } else { $controlResult.AddMessage([VerificationResult]::Manual, "Regular expressions for detecting SC-ALT account is not defined in the organization."); } } else{ $controlResult.AddMessage([VerificationResult]::Error, "Regular expressions for detecting SC-ALT account is not defined in the organization. Please update your ControlSettings.json as per the latest AzSK.ADO PowerShell module."); } } else { #count is 0 then there is no members added in the admin groups $controlResult.AddMessage([VerificationResult]::Passed, "Admin groups does not have any members."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not find the list of administrator groups in the organization."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not find the list of groups in the organization."); } } else { $controlResult.AddMessage([VerificationResult]::Manual, "List of administrator groups for detecting non SC-Alt accounts is not defined in your organization."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "List of administrator groups for detecting non SC-Alt accounts is not defined in your organization. Please update your ControlSettings.json as per the latest AzSK.ADO PowerShell module."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of groups in the organization."); } return $controlResult } hidden [ControlResult] CheckAADConfiguration([ControlResult] $controlResult) { try { $apiURL = "https://dev.azure.com/{0}/_settings/organizationAad?__rt=fps&__ver=2" -f $($this.SubscriptionContext.SubscriptionName); $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); if(([Helpers]::CheckMember($responseObj[0],"fps.dataProviders.data") ) -and (($responseObj[0].fps.dataProviders.data."ms.vss-admin-web.organization-admin-aad-data-provider") -and $responseObj[0].fps.dataProviders.data."ms.vss-admin-web.organization-admin-aad-data-provider".orgnizationTenantData) -and (-not [string]::IsNullOrWhiteSpace($responseObj[0].fps.dataProviders.data."ms.vss-admin-web.organization-admin-aad-data-provider".orgnizationTenantData.domain))) { $controlResult.AddMessage([VerificationResult]::Passed, "Organization is configured with [$($responseObj.fps.dataProviders.data.'ms.vss-admin-web.organization-admin-aad-data-provider'.orgnizationTenantData.displayName)] directory."); $controlResult.AdditionalInfo += "Organization is configured with [$($responseObj.fps.dataProviders.data.'ms.vss-admin-web.organization-admin-aad-data-provider'.orgnizationTenantData.displayName)] directory."; } else { $controlResult.AddMessage([VerificationResult]::Failed, "Organization is not configured with AAD."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch AAD configuration details."); } return $controlResult } hidden [ControlResult] CheckAltAuthSettings([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"applicationConnection")) { try { #https://devblogs.microsoft.com/devops/azure-devops-will-no-longer-support-alternate-credentials-authentication/ $altAuthObj = $this.OrgPolicyObj.applicationConnection | Where-Object {$_.Policy.Name -eq "Policy.DisallowBasicAuthentication"} if(($altAuthObj | Measure-Object).Count -gt 0) { if($altAuthObj.policy.effectiveValue -eq $false ) { $controlResult.AddMessage([VerificationResult]::Passed, "Alternate authentication is disabled in organization."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Alternate authentication is enabled in organization."); } } } catch { $controlResult.AddMessage([VerificationResult]::Passed, "Alternate authentication is no longer supported in Azure DevOps."); } } return $controlResult } hidden [ControlResult] CheckExternalUserPolicy([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"user")) { $userPolicyObj = $this.OrgPolicyObj.user; $guestAuthObj = $userPolicyObj | Where-Object {$_.Policy.Name -eq "Policy.DisallowAadGuestUserAccess"} if(($guestAuthObj | Measure-Object).Count -gt 0) { if($guestAuthObj.policy.effectiveValue -eq $false ) { $controlResult.AddMessage([VerificationResult]::Passed,"External guest access is disabled in the organization."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "External guest access is enabled in the organization."); } } else { #Manual control status because external guest access notion is not applicable when AAD is not configured. Instead invite GitHub user policy is available in non-AAD backed orgs. $controlResult.AddMessage([VerificationResult]::Manual, "Could not fetch external guest access policy details of the organization. This policy is available only when the organization is connected to AAD."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch user policy details of the organization."); } return $controlResult } hidden [ControlResult] CheckPublicProjectPolicy([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"security")) { $guestAuthObj = $this.OrgPolicyObj.security | Where-Object {$_.Policy.Name -eq "Policy.AllowAnonymousAccess"} if(($guestAuthObj | Measure-Object).Count -gt 0) { if($guestAuthObj.policy.effectiveValue -eq $false ) { $controlResult.AddMessage([VerificationResult]::Passed, "Public projects are not allowed in the organization."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Public projects are allowed in the organization."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the public project security policies."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the organization security policies."); } return $controlResult } hidden [ControlResult] ValidateInstalledExtensions([ControlResult] $controlResult) { try { $apiURL = "https://extmgmt.dev.azure.com/{0}/_apis/extensionmanagement/installedextensions?api-version=4.1-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); if(($responseObj | Measure-Object).Count -gt 0 ) #includes both custom installed and built in extensions. { $extensionList = $responseObj | Select-Object extensionName,publisherId,publisherName,version,flags,lastPublished,scopes,extensionId # 'flags' is not available in every extension. It is visible only for built in extensions. Hence this appends 'flags' to trimmed objects. $extensionList = $extensionList | Where-Object {$_.flags -notlike "*builtin*" } # to filter out extensions that are built in and are not visible on portal. $ftWidth = 512 #Used for table output width to avoid "..." truncation $dsMarker = '################################################################################' #To mark detail scan sections $extCount = ($extensionList | Measure-Object ).Count; if($extCount -gt 0) { $controlResult.AddMessage("No. of installed extensions: " + $extCount); $controlResult.AdditionalInfo += "No. of installed extensions: " + $extCount; #if([Helpers]::CheckMember($this.ControlSettings, "Organization.TrustedExtensionPublishersId")) #{$trustedExtPublishersId = $this.ControlSettings.Organization.TrustedExtensionPublishersId;} $trustedExtPublishers = $this.ControlSettings.Organization.TrustedExtensionPublishers; $trustedExtensions = @(); #Publishers trusted by Microsoft #$trustedExtensions += $extensionList | Where-Object {$_.publisherId -in $trustedExtPublishersId} $trustedExtensions += $extensionList | Where-Object {$_.publisherName -in $trustedExtPublishers} $trustedCount = ($trustedExtensions | Measure-Object).Count $unTrustedExtensions = @(); #Publishers not trusted by Microsoft #$unTrustedExtensions += $extensionList | Where-Object {$_.publisherId -notin $trustedExtPublishersId} $unTrustedExtensions += $extensionList | Where-Object {$_.publisherName -notin $trustedExtPublishers} $unTrustedCount = ($unTrustedExtensions | Measure-Object).Count $controlResult.AddMessage("`nNote: The following publishers are considered as 'trusted': `n`t[$($trustedExtPublishers -join ', ')]"); $controlResult.AddMessage([VerificationResult]::Verify, "`nReview the list of installed extensions for your org: "); if($unTrustedCount -gt 0){ $controlResult.AddMessage("`nNo. of extensions (from publishers not in 'trusted publishers' list): $unTrustedCount"); $controlResult.AdditionalInfo += "No. of installed extensions (from publishers not in 'trusted publishers' list): " + $unTrustedCount; $controlResult.AddMessage("`nExtension details: ") $display = ($unTrustedExtensions | FT ExtensionName, publisherId, publisherName, Version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) $controlResult.AdditionalInfo += "Installed extensions (from untrusted publishers): " + [JsonHelper]::ConvertToJsonCustomCompressed($unTrustedExtensions); } if($trustedCount -gt 0){ $controlResult.AddMessage("`nNo. of extensions (from publishers in the 'trusted publishers' list): $trustedCount"); $controlResult.AdditionalInfo += "No. of extensions (from publishers in the 'trusted publishers' list): " + $trustedCount; $controlResult.AddMessage("`nExtension details: ") $display = ($trustedExtensions|FT ExtensionName, publisherId, publisherName, Version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } $stateData = @{ Trusted_Extensions = @(); Untrusted_Extensions = @(); }; $stateData.Trusted_Extensions += $trustedExtensions $stateData.Untrusted_Extensions += $unTrustedExtensions $controlResult.SetStateData("List of installed extensions: ", $stateData); ## Deep scan start if([AzSKRoot]::IsDetailedScanRequired -eq $true) { if($null -ne $this.ControlSettings) { # find inactive extensions if([Helpers]::CheckMember($this.ControlSettings, "Organization.ExtensionsLastUpdatedInYears")) { $staleExtensionList=@() $date = Get-Date $ExtensionsLastUpdatedInYears = $this.ControlSettings.Organization.ExtensionsLastUpdatedInYears $controlResult.AddMessage("`n$dsMarker`nLooking for extensions that have not been updated by publishers for more than [$ExtensionsLastUpdatedInYears] years...") $thresholdDate = $date.AddYears(-$ExtensionsLastUpdatedInYears) $staleExtensionList += $extensionList | Where-Object {([datetime] $_.lastPublished) -lt $thresholdDate} if($staleExtensionList.count -gt 0) { $controlResult.AddMessage("`nNo. of extensions that haven't been updated in the last [$ExtensionsLastUpdatedInYears] years: "+ $staleExtensionList.count) $controlResult.AddMessage("`nExtension details (oldest first): ") $display = ($staleExtensionList| Sort-Object lastPublished | FT ExtensionName, PublisherId, PublisherName, version, lastPublished -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } } # display extensions with critical scopes if([Helpers]::CheckMember($this.ControlSettings, "Organization.ExtensionCriticalScopes")) { $ExtensionListWithCriticalScopes=@() $ExtensionCriticalScopes=$this.ControlSettings.Organization.ExtensionCriticalScopes; $controlResult.AddMessage("`n$dsMarker`nLooking for extensions that have sensitive access permissions...") $controlResult.AddMessage("Note: The following permissions are considered sensitive: `n`t[$($ExtensionCriticalScopes -join ', ')]") $ExtensionListWithCriticalScopes += ($extensionList | ? { (@($_.scopes | ? {$_ -in $ExtensionCriticalScopes})).count -gt 0}) if($ExtensionListWithCriticalScopes.count -gt 0) { $controlResult.AddMessage("`nNo. of extensions that have sensitive access permissions: "+ $ExtensionListWithCriticalScopes.count) $controlResult.AddMessage("`nExtension details: ") $display= ($ExtensionListWithCriticalScopes | FT ExtensionName, PublisherId, PublisherName, scopes -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } } # Avoid extensions with 'DevTest', 'Demo', 'Preview', 'Deprecated' in names if([Helpers]::CheckMember($this.ControlSettings, "Organization.NonProductionExtensionIndicators")) { $ExtensionListWithNonProductionExtensionIndicators=@() $NonProductionExtensionIndicators=$this.ControlSettings.Organization.NonProductionExtensionIndicators; $controlResult.AddMessage("`n$dsMarker`nLooking for extensions that are not production ready...") $controlResult.AddMessage("Note: This checks for extensions with words [$($NonProductionExtensionIndicators -join ', ')] in their names.") for($i=0;$i -lt $extensionList.count;$i++) { for($j=0;$j -lt $NonProductionExtensionIndicators.Count;$j++) { if($extensionList[$i].extensionName -match $NonProductionExtensionIndicators[$j]) { $ExtensionListWithNonProductionExtensionIndicators += $extensionList[$i] break; #Move to the next extension } } } if($ExtensionListWithNonProductionExtensionIndicators.count -gt 0) { $controlResult.AddMessage("`nNo. of non-production extensions (based on name): "+ $ExtensionListWithNonProductionExtensionIndicators.count) $controlResult.AddMessage("`nExtension details: ") $controlResult.AddMessage( ($ExtensionListWithNonProductionExtensionIndicators | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth)) } } # Display extensions with Top Publishers, extensions that are private and Non-prod extensions $topPublisherExt=@() $privateExtensions=@() $nonProdExtensions=@() $extensionList | ForEach-Object { $url="https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery?api-version=6.1-preview.1" $inputbody = "{ 'assetTypes': null, 'filters': [ { 'criteria': [ { 'filterType': 7, 'value': '$($_.publisherId).$($_.extensionId)' } ] } ] }" $response= Invoke-WebRequest -Uri $url ` -Method Post ` -ContentType "application/json" ` -Body $inputbody ` -UseBasicParsing $responseObject=$response.Content | ConvertFrom-Json # if response object does not get details of extension, those extensions are private extensions if([Helpers]::CheckMember($responseobject.results[0], "extensions") -eq $false ) { $privateExtensions+=$_ } else { $extensionflags=$responseobject.results[0].extensions.flags if($extensionflags -match 'Preview') { $nonProdExtensions+=$_ } $publisherFlags = $responseobject.results[0].extensions.publisher.flags if($publisherFlags -match "Certified") { $topPublisherExt+=$_ } } } if($nonProdExtensions.count -gt 0) { $controlResult.AddMessage("`n$dsMarker`nLooking for extensions that are marked 'Preview' via Gallery flags...") $controlResult.AddMessage("`nNo. of installed extensions marked as 'Preview' via Gallery flags: "+ $nonProdExtensions.count); $controlResult.AddMessage("`nExtension details: ") $controlResult.AddMessage(($nonProdExtensions | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth)); } if($topPublisherExt.count -gt 0) { $controlResult.AddMessage("`n$dsMarker`nLooking for extensions that are from publishers with a 'Top Publisher' certification..."); $controlResult.AddMessage("`nNo. of installed extensions from 'Top Publishers': "+$topPublisherExt.count); $controlResult.AddMessage("`nExtension details: ") $controlResult.AddMessage(($topPublisherExt | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth) ); } if($privateExtensions.count -gt 0) { $controlResult.AddMessage("`n$dsMarker`nLooking for extensions that have 'private' visibility for the org..."); $controlResult.AddMessage("`nNo. of installed extensions with 'private' visibility: "+$privateExtensions.count); $controlResult.AddMessage("`nExtension details: ") $controlResult.AddMessage(($privateExtensions | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth)); } } } ## end Deep scan } else { $controlResult.AddMessage([VerificationResult]::Passed, "No installed extensions found."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No installed extensions found."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of installed extensions."); } return $controlResult } hidden [ControlResult] ValidateSharedExtensions([ControlResult] $controlResult) { $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $orgURL="https://dev.azure.com/{0}/_settings/extensions" -f $($this.SubscriptionContext.SubscriptionName); $inputbody = "{'contributionIds':['ms.vss-extmgmt-web.ext-management-hub'],'dataProviderContext':{'properties':{'sourcePage':{'url':'$orgURL','routeId':'ms.vss-admin-web.collection-admin-hub-route','routeValues':{'adminPivot':'extensions','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json try { $responseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); if([Helpers]::CheckMember($responseObj[0],"dataProviders") -and $responseObj[0].dataProviders.'ms.vss-extmgmt-web.extensionManagmentHub-collection-data-provider') { $sharedExtensions = $responseObj[0].dataProviders.'ms.vss-extmgmt-web.extensionManagmentHub-collection-data-provider'.sharedExtensions if(($sharedExtensions | Measure-Object).Count -gt 0) { $controlResult.AddMessage("No. of shared extensions: " + $sharedExtensions.Count) $controlResult.AdditionalInfo += "No. of shared extensions: " + ($sharedExtensions | Measure-Object).Count; $extensionList = @(); $extensionList += ($sharedExtensions | Select-Object extensionName, publisherId, publisherName, version) $controlResult.AddMessage([VerificationResult]::Verify, "Review the below list of shared extensions: "); $ftWidth = 512 #To avoid "..." truncation $display = ($extensionList | FT ExtensionName, publisherId, publisherName, Version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) $controlResult.SetStateData("List of shared extensions: ", $extensionList); $controlResult.AdditionalInfo += "List of shared extensions: " + [JsonHelper]::ConvertToJsonCustomCompressed($extensionList); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No shared extensions found."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of shared extensions."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of shared extensions."); } return $controlResult } hidden [ControlResult] CheckGuestIdentities([ControlResult] $controlResult) { try { $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?%24filter=userType%20eq%20%27guest%27&%24orderBy=name%20Ascending&api-version=5.1-preview.3" -f $($this.SubscriptionContext.SubscriptionName); $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); # returns a maximum of 100 guest users $guestUsers = @() if(($responseObj -ne $null) -and $responseObj.Count -gt 0 -and ([Helpers]::CheckMember($responseObj[0], 'members'))) { $guestUsers += $responseObj[0].members $continuationToken = $responseObj[0].continuationToken # Use the continuationToken for pagination while ($continuationToken -ne $null){ $urlEncodedToken = [System.Web.HttpUtility]::UrlEncode($continuationToken) $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?continuationToken=$urlEncodedToken&%24filter=userType%20eq%20%27guest%27&%24orderBy=name%20Ascending&api-version=5.1-preview.3" -f $($this.SubscriptionContext.SubscriptionName); try{ $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $guestUsers += $responseObj[0].members $continuationToken = $responseObj[0].continuationToken } catch { # Eating the exception here as we could not fetch the further guest users $continuationToken = $null } } $guestList = @(); $guestList += ($guestUsers | Select-Object @{Name="Id"; Expression = {$_.id}},@{Name="IdentityType"; Expression = {$_.user.subjectKind}},@{Name="DisplayName"; Expression = {$_.user.displayName}}, @{Name="MailAddress"; Expression = {$_.user.mailAddress}},@{Name="AccessLevel"; Expression = {$_.accessLevel.licenseDisplayName}},@{Name="LastAccessedDate"; Expression = {$_.lastAccessedDate}},@{Name="InactiveFromDays"; Expression = { if (((Get-Date) -[datetime]::Parse($_.lastAccessedDate)).Days -gt 10000){return "User was never active."} else {return ((Get-Date) -[datetime]::Parse($_.lastAccessedDate)).Days} }}) $stateData = @(); $stateData += ($guestUsers | Select-Object @{Name="Id"; Expression = {$_.id}},@{Name="IdentityType"; Expression = {$_.user.subjectKind}},@{Name="DisplayName"; Expression = {$_.user.displayName}}, @{Name="MailAddress"; Expression = {$_.user.mailAddress}}) # $guestListDetailed would be same if DetailedScan is not enabled. $guestListDetailed = $guestList if([AzSKRoot]::IsDetailedScanRequired -eq $true) { # If DetailedScan is enabled. fetch the project entitlements for the guest user $guestListDetailed = $guestList | ForEach-Object { try{ $guestUser = $_ $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/userentitlements/{1}?api-version=6.0-preview.3" -f $($this.SubscriptionContext.SubscriptionName), $($guestUser.Id); $projectEntitlements = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $userProjectEntitlements = $projectEntitlements[0].projectEntitlements } catch { $userProjectEntitlements = "Could not fetch project entitlement details of the user." } return @{Id = $guestUser.Id; IdentityType = $guestUser.IdentityType; DisplayName = $guestUser.IdentityType; MailAddress = $guestUser.MailAddress; AccessLevel = $guestUser.AccessLevel; LastAccessedDate = $guestUser.LastAccessedDate; InactiveFromDays = $guestUser.InactiveFromDays; ProjectEntitlements = $userProjectEntitlements} } } $totalGuestCount = ($guestListDetailed | Measure-Object).Count $controlResult.AddMessage("Displaying all guest users in the organization..."); $controlResult.AddMessage([VerificationResult]::Verify,"Total number of guest users in the organization: $($totalGuestCount)"); $controlResult.AdditionalInfo += "Total number of guest users in the organization: " + $totalGuestCount; $inactiveGuestUsers = $guestListDetailed | Where-Object { $_.InactiveFromDays -eq "User was never active." } $inactiveCount = ($inactiveGuestUsers | Measure-Object).Count if($inactiveCount) { $controlResult.AddMessage("`nTotal number of guest users who were never active: $($inactiveCount)"); $controlResult.AdditionalInfo += "Total number of inactive guest users in the organization: " + $inactiveCount; $controlResult.AddMessage("List of guest users who were never active: ",$inactiveGuestUsers); } $activeGuestUsers = $guestListDetailed | Where-Object { $_.InactiveFromDays -ne "User was never active." } $activeCount = ($activeGuestUsers | Measure-Object).Count if($activeCount) { $controlResult.AddMessage("`nTotal number of guest users who are active: $($activeCount)"); $controlResult.AdditionalInfo += "Total number of active guest users in the organization: " + $activeCount; $controlResult.AddMessage("List of guest users who are active: ",$activeGuestUsers); } $controlResult.SetStateData("Guest users list: ", $stateData); } else #external guest access notion is not applicable when AAD is not configured. Instead GitHub user notion is available in non-AAD backed orgs. { $controlResult.AddMessage([VerificationResult]::Passed, "There are no guest users in the organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of guest identities."); } return $controlResult } hidden [ControlResult] CheckExtensionManagers([ControlResult] $controlResult) { $apiURL = "https://extmgmt.dev.azure.com/{0}/_apis/securityroles/scopes/ems.manage.ui/roleassignments/resources/ems-ui" -f $($this.SubscriptionContext.SubscriptionName); try { $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); # If no ext. managers are present, 'count' property is available for $responseObj[0] and its value is 0. # If ext. managers are assigned, 'count' property is not available for $responseObj[0]. #'Count' is a PSObject property and 'count' is response object property. Notice the case sensitivity here. # TODO: When there are no managers check member in the below condition returns false when checknull flag [third param in CheckMember] is not specified (default value is $true). Assiging it $false. Need to revisit. if(([Helpers]::CheckMember($responseObj[0],"count",$false)) -and ($responseObj[0].count -eq 0)) { $controlResult.AddMessage([VerificationResult]::Passed, "No extension managers assigned."); } # When there are managers - the below condition will be true. elseif((-not ([Helpers]::CheckMember($responseObj[0],"count"))) -and ($responseObj.Count -gt 0)) { $controlResult.AddMessage("No. of extension managers present: " + $responseObj.Count) $controlResult.AdditionalInfo += "No. of extension managers present: " + ($responseObj | Measure-Object).Count; $extensionManagerList = @(); $extensionManagerList += ($responseObj | Select-Object @{Name="IdentityName"; Expression = {$_.identity.displayName}},@{Name="Role"; Expression = {$_.role.displayName}}) $controlResult.AddMessage([VerificationResult]::Verify, "Review the below list of extension managers: ",$extensionManagerList); $controlResult.SetStateData("List of extension managers: ", $extensionManagerList); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No extension managers assigned."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of extension managers."); } return $controlResult } hidden [ControlResult] CheckInactiveUsers([ControlResult] $controlResult) { try { $topInactiveUsers = $this.ControlSettings.Organization.TopInactiveUserCount $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?top={1}&filter=&sortOption=lastAccessDate+ascending" -f $($this.SubscriptionContext.SubscriptionName), $topInActiveUsers; $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); if($responseObj.Count -gt 0) { $inactiveUsers = @() $responseObj[0].items | ForEach-Object { if([datetime]::Parse($_.lastAccessedDate) -lt ((Get-Date).AddDays(-$($this.ControlSettings.Organization.InActiveUserActivityLogsPeriodInDays)))) { $inactiveUsers+= $_ } } if(($inactiveUsers | Measure-Object).Count -gt 0) { if($inactiveUsers.Count -ge $topInactiveUsers) { $controlResult.AddMessage("Displaying top $($topInactiveUsers) inactive users") } #inactive user with days from how many days user is inactive, if user account created and was never active, in this case lastaccessdate is default 01-01-0001 $inactiveUsers = ($inactiveUsers | Select-Object -Property @{Name="Name"; Expression = {$_.User.displayName}},@{Name="mailAddress"; Expression = {$_.User.mailAddress}},@{Name="InactiveFromDays"; Expression = { if (((Get-Date) -[datetime]::Parse($_.lastAccessedDate)).Days -gt 10000){return "User was never active."} else {return ((Get-Date) -[datetime]::Parse($_.lastAccessedDate)).Days} }}) #set data for attestation $inactiveUsersStateData = ($inactiveUsers | Select-Object -Property @{Name="Name"; Expression = {$_.Name}},@{Name="mailAddress"; Expression = {$_.mailAddress}}) $inactiveUsersCount = ($inactiveUsers | Measure-Object).Count $controlResult.AddMessage([VerificationResult]::Failed,"Total number of inactive users present in the organization: $($inactiveUsersCount)"); $controlResult.AdditionalInfo += "Total number of inactive users present in the organization: " + $inactiveUsersCount; $controlResult.SetStateData("Inactive users list: ", $inactiveUsersStateData); # segregate never active users from the list $neverActiveUsers = $inactiveUsers | Where-Object {$_.InactiveFromDays -eq "User was never active."} $inactiveUsersWithDays = $inactiveUsers | Where-Object {$_.InactiveFromDays -ne "User was never active."} $neverActiveUsersCount = ($neverActiveUsers | Measure-Object).Count if ($neverActiveUsersCount -gt 0) { $controlResult.AddMessage("`nTotal number of users who were never active: $($neverActiveUsersCount)"); $controlResult.AddMessage("Review users present in the organization who were never active: ",$neverActiveUsers); $controlResult.AdditionalInfo += "Total number of users who were never active: " + $neverActiveUsersCount; $controlResult.AdditionalInfo += "List of users who were never active: " + [JsonHelper]::ConvertToJsonCustomCompressed($neverActiveUsers); } $inactiveUsersWithDaysCount = ($inactiveUsersWithDays | Measure-Object).Count if($inactiveUsersWithDaysCount -gt 0) { $controlResult.AddMessage("`nTotal number of users who are inactive from last $($this.ControlSettings.Organization.InActiveUserActivityLogsPeriodInDays) days: $($inactiveUsersWithDaysCount)"); $controlResult.AddMessage("Review users present in the organization who are inactive from last $($this.ControlSettings.Organization.InActiveUserActivityLogsPeriodInDays) days: ",$inactiveUsersWithDays); $controlResult.AdditionalInfo += "Total number of users who are inactive from last $($this.ControlSettings.Organization.InActiveUserActivityLogsPeriodInDays) days: " + $inactiveUsersWithDaysCount; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No inactive users found.") } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No inactive users found."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of users in the organization."); } return $controlResult; } hidden [ControlResult] CheckDisconnectedIdentities([ControlResult] $controlResult) { try { $apiURL = "https://dev.azure.com/{0}/_apis/OrganizationSettings/DisconnectedUser" -f $($this.SubscriptionContext.SubscriptionName); $responseObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); #disabling null check to CheckMember because if there are no disconnected users - it will return null. if ([Helpers]::CheckMember($responseObj[0], "users",$false)) { if (($responseObj[0].users | Measure-Object).Count -gt 0 ) { $userNames = @(); $userNames += ($responseObj[0].users | Select-Object -Property @{Name = "Name"; Expression = { $_.displayName } }, @{Name = "mailAddress"; Expression = { $_.preferredEmailAddress } }) $controlResult.AddMessage("Total number of disconnected users: ", ($userNames | Measure-Object).Count); $controlResult.AddMessage([VerificationResult]::Failed, "Remove access for below disconnected users: ", $userNames); $controlResult.SetStateData("Disconnected users list: ", $userNames); $controlResult.AdditionalInfo += "Total number of disconnected users: " + ($userNames | Measure-Object).Count; $controlResult.AdditionalInfo += "List of disconnected users: " + [JsonHelper]::ConvertToJsonCustomCompressed($userNames); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No disconnected users found."); } } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of disconnected users."); } return $controlResult; } hidden [ControlResult] CheckRBACAccess([ControlResult] $controlResult) { $url= "https://vssps.dev.azure.com/{0}/_apis/graph/groups?api-version=5.1-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $groupsObj = [WebRequestHelper]::InvokeGetWebRequest($url); $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?top=50&filter=&sortOption=lastAccessDate+ascending" -f $($this.SubscriptionContext.SubscriptionName); $usersObj = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $Users = @() $usersObj[0].items | ForEach-Object { $Users+= $_ } $groups = ($groupsObj | Select-Object -Property @{Name="Name"; Expression = {$_.displayName}},@{Name="mailAddress"; Expression = {$_.mailAddress}}); $UsersNames = ($Users | Select-Object -Property @{Name="Name"; Expression = {$_.User.displayName}},@{Name="mailAddress"; Expression = {$_.User.mailAddress}}) if ( (($groups | Measure-Object).Count -gt 0) -or (($UsersNames | Measure-Object).Count -gt 0)) { $controlResult.AddMessage([VerificationResult]::Verify, "Verify users and groups present on Organization"); $controlResult.AddMessage("Verify groups present on Organization", $groups); $controlResult.AddMessage("Verify users present on Organization", $UsersNames); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users or groups found"); } return $controlResult } hidden [ControlResult] JustifyGroupMember([ControlResult] $controlResult) { $grpmember = @(); $url= "https://vssps.dev.azure.com/{0}/_apis/graph/groups?api-version=5.1-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $groupsObj = [WebRequestHelper]::InvokeGetWebRequest($url); $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview" -f $($this.SubscriptionContext.SubscriptionName); $membercount =0; Foreach ($group in $groupsObj){ $groupmember = @(); $descriptor = $group.descriptor; $inputbody = '{"contributionIds":["ms.vss-admin-web.org-admin-members-data-provider"],"dataProviderContext":{"properties":{"subjectDescriptor":"","sourcePage":{"url":"","routeId":"ms.vss-admin-web.collection-admin-hub-route","routeValues":{"adminPivot":"groups","controller":"ContributedPage","action":"Execute"}}}}}' | ConvertFrom-Json $inputbody.dataProviderContext.properties.subjectDescriptor = $descriptor; $inputbody.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.SubscriptionContext.SubscriptionName)/_settings/groups?subjectDescriptor=$($descriptor)"; $usersObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); $usersObj.dataProviders."ms.vss-admin-web.org-admin-members-data-provider".identities | ForEach-Object { $groupmember += $_; } $grpmember = ($groupmember | Select-Object -Property @{Name="Name"; Expression = {$_.displayName}},@{Name="mailAddress"; Expression = {$_.mailAddress}}); if ($grpmember -ne $null) { $membercount= $membercount + 1 $controlResult.AddMessage("Verify below members of the group: '$($group.principalname)', Description: $($group.description)", $grpmember); } } if ( $membercount -gt 0) { $controlResult.AddMessage([VerificationResult]::Verify, "Verify members of groups present on Organization"); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users or groups found"); } return $controlResult } hidden [ControlResult] CheckOAuthAppAccess([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"applicationConnection")) { $OAuthObj = $this.OrgPolicyObj.applicationConnection | Where-Object {$_.Policy.Name -eq "Policy.DisallowOAuthAuthentication"} if(($OAuthObj | Measure-Object).Count -gt 0) { if($OAuthObj.policy.effectiveValue -eq $true ) { $controlResult.AddMessage([VerificationResult]::Failed, "Third-party application access via OAuth is enabled."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Third-party application access via OAuth is disabled."); } } } return $controlResult } hidden [ControlResult] CheckSSHAuthN([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"applicationConnection")) { $SSHAuthObj = $this.OrgPolicyObj.applicationConnection | Where-Object {$_.Policy.Name -eq "Policy.DisallowSecureShell"} if(($SSHAuthObj | Measure-Object).Count -gt 0) { if($SSHAuthObj.policy.effectiveValue -eq $true ) { $controlResult.AddMessage([VerificationResult]::Failed, "Connecting to Git repos via SSH authentication is enabled in the organization."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Connecting to Git repos via SSH authentication is disabled in the organization."); } } } return $controlResult } hidden [ControlResult] CheckEnterpriseAccess([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"security")) { $CAPObj = $this.OrgPolicyObj.security | Where-Object {$_.Policy.Name -eq "Policy.AllowOrgAccess"} if(($CAPObj | Measure-Object).Count -gt 0) { if($CAPObj.policy.effectiveValue -eq $true ) { $controlResult.AddMessage([VerificationResult]::Verify, "Enterprise access to projects is enabled."); } else { $controlResult.AddMessage([VerificationResult]::Passed, "Enterprise access to projects is disabled."); } } } return $controlResult } hidden [ControlResult] CheckCAP([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"security")) { $CAPObj = $this.OrgPolicyObj.security | Where-Object {$_.Policy.Name -eq "Policy.EnforceAADConditionalAccess"} if(($CAPObj | Measure-Object).Count -gt 0) { if($CAPObj.policy.effectiveValue -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "AAD conditional access policy validation is enabled."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "AAD conditional access policy validation is disabled."); } } } return $controlResult } hidden [ControlResult] CheckBadgeAnonAccess([ControlResult] $controlResult) { if($this.PipelineSettingsObj) { if($this.PipelineSettingsObj.statusBadgesArePrivate -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Anonymous access to status badge API is disabled."); } else{ $controlResult.AddMessage([VerificationResult]::Failed, "Anonymous access to status badge API is enabled."); } } else{ $controlResult.AddMessage([VerificationResult]::Manual, "Pipeline settings could not be fetched due to insufficient permissions at organization scope."); } return $controlResult } hidden [ControlResult] CheckSettableQueueTime([ControlResult] $controlResult) { if($this.PipelineSettingsObj) { if($this.PipelineSettingsObj.enforceSettableVar -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Only limited variables can be set at queue time."); } else{ $controlResult.AddMessage([VerificationResult]::Failed, "All variables can be set at queue time."); } } else{ $controlResult.AddMessage([VerificationResult]::Manual, "Pipeline settings could not be fetched due to insufficient permissions at organization scope."); } return $controlResult } hidden [ControlResult] CheckJobAuthZScope([ControlResult] $controlResult) { if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.enforceJobAuthScope if($orgLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Job authorization scope is limited to current project for non-release pipelines at organization level."); } else{ $controlResult.AddMessage([VerificationResult]::Failed, "Job authorization scope is set to project collection for non-release pipelines at organization level."); } } else{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the organization pipeline settings."); } return $controlResult } hidden [ControlResult] CheckJobAuthZReleaseScope([ControlResult] $controlResult) { if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.enforceJobAuthScopeForReleases if($orgLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Job authorization scope is limited to current project for release pipelines at organization level."); } else{ $controlResult.AddMessage([VerificationResult]::Failed, "Job authorization scope is set to project collection for release pipelines at organization level."); } } else{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the organization pipeline settings."); } return $controlResult } hidden [ControlResult] CheckAuthZRepoScope([ControlResult] $controlResult) { if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.enforceReferencedRepoScopedToken if($orgLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Job authorization scope of pipelines is limited to explicitly referenced Azure DevOps repositories at organization level."); } else{ $controlResult.AddMessage([VerificationResult]::Failed, "Job authorization scope of pipelines is set to all Azure DevOps repositories in the authorized projects at organization level."); } } else{ $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the organization pipeline settings."); } return $controlResult } hidden [ControlResult] CheckBuiltInTask([ControlResult] $controlResult) { <# This control has been currently removed from control JSON file. { "ControlID": "ADO_Organization_SI_Review_BuiltIn_Tasks_Setting", "Description": "Review built-in tasks from being used in pipelines.", "Id": "Organization334", "ControlSeverity": "Medium", "Automated": "Yes", "MethodName": "CheckBuiltInTask", "Rationale": "Running built-in tasks from untrusted source can lead to all type of attacks and loss of sensitive enterprise data.", "Recommendation": "Go to Organization settings --> Pipelines --> Settings --> Task restrictions --> Turn on 'Disable built-in tasks' flag.", "Tags": [ "SDL", "TCP", "Automated", "SI" ], "Enabled": true }, #> if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.disableInBoxTasksVar if($orgLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Built-in tasks are disabled at organization level."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Built-in tasks are not disabled at organization level."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the organization pipeline settings."); } return $controlResult } hidden [ControlResult] CheckMarketplaceTask([ControlResult] $controlResult) { <# This control has been currently removed from control JSON file. { "ControlID": "ADO_Organization_SI_Review_Marketplace_Tasks_Setting", "Description": "Review Marketplace tasks from being used in pipelines.", "Id": "Organization336", "ControlSeverity": "Medium", "Automated": "Yes", "MethodName": "CheckMarketplaceTask", "Rationale": "Running Marketplace tasks from untrusted source can lead to all type of attacks and loss of sensitive enterprise data.", "Recommendation": "Go to Organization settings --> Pipelines --> Settings --> Task restrictions --> Turn on 'Disable Marketplace tasks'.", "Tags": [ "SDL", "TCP", "Automated", "SI" ], "Enabled": true }, #> if($this.PipelineSettingsObj) { $orgLevelScope = $this.PipelineSettingsObj.disableMarketplaceTasksVar if($orgLevelScope -eq $true ) { $controlResult.AddMessage([VerificationResult]::Passed, "Market place tasks are disabled at organization level."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Market place tasks are not disabled at organization level."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the organization pipeline settings."); } return $controlResult } hidden [ControlResult] CheckPolicyProjectTeamAdminUserInvitation([ControlResult] $controlResult) { if([Helpers]::CheckMember($this.OrgPolicyObj,"user")) { $userPolicyObj = $this.OrgPolicyObj.user $userInviteObj = $userPolicyObj | Where-Object {$_.Policy.Name -eq "Policy.AllowTeamAdminsInvitationsAccessToken"} if(($userInviteObj | Measure-Object).Count -gt 0) { if($userInviteObj.policy.effectiveValue -eq $false ) { $controlResult.AddMessage([VerificationResult]::Passed,"Team and project administrators are not allowed to invite new users."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Team and project administrators are allowed to invite new users."); } } else { #Manual control status because the notion of team and project admins inviting new users is not applicable when AAD is not configured. $controlResult.AddMessage([VerificationResult]::Manual, "Could not fetch invite new user policy details of the organization. This policy is available only when the organization is connected to AAD."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch user policy details of the organization."); } return $controlResult } hidden [ControlResult] CheckRequestAccessPolicy([ControlResult] $controlResult) { <# This control has been currently removed from control JSON file. { "ControlID": "ADO_Organization_AuthZ_Disable_Request_Access", "Description": "Stop your users from requesting access to your organization or project within your organization, by disabling the request access policy.", "Id": "Organization339", "ControlSeverity": "Medium", "Automated": "Yes", "MethodName": "CheckRequestAccessPolicy", "Rationale": "When request access policy is enabled, users can request access to a resource. Disabling this policy will prevent users from requesting access to organization or project within the organization.", "Recommendation": "Go to Organization Settings --> Policy --> User Policy --> Disable 'Request Access'.", "Tags": [ "SDL", "TCP", "Automated", "AuthZ" ], "Enabled": true }, #> if([Helpers]::CheckMember($this.OrgPolicyObj,"user")) { $userPolicyObj = $this.OrgPolicyObj.user $requestAccessObj = $userPolicyObj | Where-Object {$_.Policy.Name -eq "Policy.AllowRequestAccessToken"} if(($requestAccessObj | Measure-Object).Count -gt 0) { if($requestAccessObj.policy.effectiveValue -eq $false ) { $controlResult.AddMessage([VerificationResult]::Passed,"Users can not request access to organization or projects within the organization."); } else { $controlResult.AddMessage([VerificationResult]::Failed, "Users can request access to organization or projects within the organization."); } } else { #Manual control status because the notion of request access is not applicable when AAD is not configured. $controlResult.AddMessage([VerificationResult]::Manual, "Could not fetch request access policy details of the organization. This policy is available only when the organization is connected to AAD."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch user policy details of the organization."); } return $controlResult } hidden [ControlResult] CheckAutoInjectedExtensions([ControlResult] $controlResult) { try { $url ="https://extmgmt.dev.azure.com/{0}/_apis/extensionmanagement/installedextensions?api-version=5.1-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $responseObj = [WebRequestHelper]::InvokeGetWebRequest($url); $autoInjExt = @(); foreach($extension in $responseObj) { foreach($cont in $extension.contributions) { if([Helpers]::CheckMember($cont,"type")) { if($cont.type -eq "ms.azure-pipelines.pipeline-decorator") { $autoInjExt += ($extension | Select-Object -Property @{Name="Name"; Expression = {$_.extensionName}},@{Name="Publisher"; Expression = {$_.PublisherName}},@{Name="Version"; Expression = {$_.version}}) break; } } } } if (($autoInjExt | Measure-Object).Count -gt 0) { $controlResult.AddMessage([VerificationResult]::Verify,"Verify the below auto-injected tasks at organization level: ", $autoInjExt); $controlResult.SetStateData("Auto-injected tasks list: ", $autoInjExt); $controlResult.AdditionalInfo += "Total number of auto-injected extensions: " + ($autoInjExt | Measure-Object).Count; $controlResult.AdditionalInfo += "List of auto-injected extensions: " + [JsonHelper]::ConvertToJsonCustomCompressed($autoInjExt); } else { $controlResult.AddMessage([VerificationResult]::Passed,"No auto-injected tasks found at organization level"); } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Couldn't fetch the list of installed extensions in the organization."); } return $controlResult } hidden [ControlResult] CheckMinPCACount([ControlResult] $controlResult) { $TotalPCAMembers=0 $PCAMembers = @() $PCAMembers += [AdministratorHelper]::GetTotalPCAMembers($this.SubscriptionContext.SubscriptionName) $TotalPCAMembers = ($PCAMembers| Measure-Object).Count $PCAMembers = $PCAMembers | Select-Object displayName,mailAddress $controlResult.AddMessage("There are a total of $TotalPCAMembers Project Collection Administrators in your organization") if($TotalPCAMembers -lt $this.ControlSettings.Organization.MinPCAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of administrators configured are less than the minimum required administrators count: $($this.ControlSettings.Organization.MinPCAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of administrators configured are more than the minimum required administrators count: $($this.ControlSettings.Organization.MinPCAMembersPermissible)"); } if($TotalPCAMembers -gt 0){ $controlResult.AddMessage("Verify the following Project Collection Administrators: ",$PCAMembers) $controlResult.SetStateData("List of Project Collection Administrators: ",$PCAMembers) $controlResult.AdditionalInfo += "Total number of Project Collection Administrators: " + $TotalPCAMembers; } return $controlResult } hidden [ControlResult] CheckMaxPCACount([ControlResult] $controlResult) { $TotalPCAMembers=0 $PCAMembers = @() $PCAMembers += [AdministratorHelper]::GetTotalPCAMembers($this.SubscriptionContext.SubscriptionName) $TotalPCAMembers = ($PCAMembers| Measure-Object).Count $PCAMembers = $PCAMembers | Select-Object displayName,mailAddress $controlResult.AddMessage("There are a total of $TotalPCAMembers Project Collection Administrators in your organization") if($TotalPCAMembers -gt $this.ControlSettings.Organization.MaxPCAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of administrators configured are more than the approved limit: $($this.ControlSettings.Organization.MaxPCAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of administrators configured are within than the approved limit: $($this.ControlSettings.Organization.MaxPCAMembersPermissible)"); } if($TotalPCAMembers -gt 0){ $controlResult.AddMessage("Verify the following Project Collection Administrators: ",$PCAMembers) $controlResult.SetStateData("List of Project Collection Administrators: ",$PCAMembers) $controlResult.AdditionalInfo += "Total number of Project Collection Administrators: " + $TotalPCAMembers; } return $controlResult } hidden [ControlResult] CheckAuditStream([ControlResult] $controlResult) { try { $url ="https://auditservice.dev.azure.com/{0}/_apis/audit/streams?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $responseObj = [WebRequestHelper]::InvokeGetWebRequest($url); # If no audit streams are configured, 'count' property is available for $responseObj[0] and its value is 0. # If audit streams are configured, 'count' property is not available for $responseObj[0]. #'Count' is a PSObject property and 'count' is response object property. Notice the case sensitivity here. # TODO: When there are no audit streams configured, CheckMember in the below condition returns false when checknull flag [third param in CheckMember] is not specified (default value is $true). Assiging it $false. Need to revisit. if(([Helpers]::CheckMember($responseObj[0],"count",$false)) -and ($responseObj[0].count -eq 0)) { $controlResult.AddMessage([VerificationResult]::Failed, "No audit stream has been configured on the organization."); } # When audit streams are configured - the below condition will be true. elseif((-not ([Helpers]::CheckMember($responseObj[0],"count"))) -and ($responseObj.Count -gt 0)) { $enabledStreams = $responseObj | Where-Object {$_.status -eq 'enabled'} $enabledStreams = $enabledStreams | Select-Object consumerType,displayName,status $enabledStreamsCount = ($enabledStreams | Measure-Object).Count $totalStreamsCount = ($responseObj | Measure-Object).Count $controlResult.AddMessage("`nTotal number of configured audit streams: $($totalStreamsCount)"); $controlResult.AdditionalInfo += "Total number of configured audit streams: " + $totalStreamsCount; if(($enabledStreams | Measure-Object).Count -gt 0) { $controlResult.AddMessage([VerificationResult]::Passed, "One or more audit streams configured on the organization are currently enabled."); $controlResult.AddMessage("`nTotal number of configured audit streams that are enabled: $($enabledStreamsCount)", $enabledStreams); $controlResult.AdditionalInfo += "Total number of configured audit streams that are enabled: " + $enabledStreamsCount; $controlResult.AdditionalInfo += "List of configured audit streams that are enabled: " + [JsonHelper]::ConvertToJsonCustomCompressed($enabledStreams); } else { $controlResult.AddMessage([VerificationResult]::Failed, "None of the audit streams that have been configured are currently enabled."); } } else { $controlResult.AddMessage([VerificationResult]::Failed, "No audit stream has been configured on the organization."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of audit streams enabled on the organization."); } return $controlResult } hidden [ControlResult] ValidateRequestedExtensions([ControlResult] $controlResult) { $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.SubscriptionContext.SubscriptionName); $orgURL="https://dev.azure.com/{0}/_settings/extensions" -f $($this.SubscriptionContext.SubscriptionName); $inputbody = "{'contributionIds':['ms.vss-extmgmt-web.ext-management-hub'],'dataProviderContext':{'properties':{'sourcePage':{'url':'$orgURL','routeId':'ms.vss-admin-web.collection-admin-hub-route','routeValues':{'adminPivot':'extensions','controller':'ContributedPage','action':'Execute'}}}}}" | ConvertFrom-Json try { $responseObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); if([Helpers]::CheckMember($responseObj[0],"dataProviders") -and $responseObj[0].dataProviders.'ms.vss-extmgmt-web.extensionManagmentHub-collection-data-provider') { $requestedExtensions = $responseObj[0].dataProviders.'ms.vss-extmgmt-web.extensionManagmentHub-collection-data-provider'.requestedExtensions $ApprovedExtensions = $requestedExtensions | Where-Object { $_.requestState -eq "1" } $PendingExtensionsForApproval = $requestedExtensions | Where-Object { $_.requestState -eq "0" } $RejectedExtensions = $requestedExtensions | Where-Object { $_.requestState -eq "2" } if(($PendingExtensionsForApproval| Measure-Object).Count -gt 0) { $extensionList = @(); $extensionList += ($PendingExtensionsForApproval | Select-Object extensionID, publisherId,@{Name="Requested By";Expression={requests.userName}}) $ftWidth = 512 #To avoid "..." truncation <#if(($ApprovedExtensions | Measure-Object).Count -gt 0) { $controlResult.AddMessage("No. of requested extensions that are approved: " + $ApprovedExtensions.Count) $controlResult.AddMessage("`nExtension details") $display = ($ApprovedExtensions | FT extensionID, publisherId,@{Name="Requested By";Expression={$_.requests.userName}} -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } if(($RejectedExtensions| Measure-Object).Count -gt 0) { $controlResult.AddMessage("No. of requested extensions that are rejected: " + $RejectedExtensions.Count) $controlResult.AddMessage("`nExtension details") $display = ($RejectedExtensions | FT extensionID, publisherId,@{Name="Requested By";Expression={$_.requests.userName}} -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } #> $controlResult.AddMessage([VerificationResult]::Verify, "`nReview the below list of pending requested extensions: "); $controlResult.AddMessage("No. of requested extensions that are pending for approval: " + $PendingExtensionsForApproval.Count) $controlResult.AddMessage("`nExtension details") $display = ($PendingExtensionsForApproval | FT extensionID, publisherId,@{Name="Requested By";Expression={$_.requests.userName}} -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) $controlResult.SetStateData("List of requested extensions: ", $extensionList); $controlResult.AdditionalInfo += "No. of pending requested extensions: " + ($PendingExtensionsForApproval | Measure-Object).Count; $controlResult.AdditionalInfo += "List of requested extensions: " + [JsonHelper]::ConvertToJsonCustomCompressed($extensionList); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No requested extensions found."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of requested extensions."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of requested extensions."); } return $controlResult } } # SIG # Begin signature block # MIIjiQYJKoZIhvcNAQcCoIIjejCCI3YCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBf/s3Kmxmze/+y # MkfWJzRiVz2WPz17vZNrlWbAJ8CCP6CCDYUwggYDMIID66ADAgECAhMzAAABiK9S # 1rmSbej5AAAAAAGIMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjAwMzA0MTgzOTQ4WhcNMjEwMzAzMTgzOTQ4WjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQCSCNryE+Cewy2m4t/a74wZ7C9YTwv1PyC4BvM/kSWPNs8n0RTe+FvYfU+E9uf0 # t7nYlAzHjK+plif2BhD+NgdhIUQ8sVwWO39tjvQRHjP2//vSvIfmmkRoML1Ihnjs # 9kQiZQzYRDYYRp9xSQYmRwQjk5hl8/U7RgOiQDitVHaU7BT1MI92lfZRuIIDDYBd # vXtbclYJMVOwqZtv0O9zQCret6R+fRSGaDNfEEpcILL+D7RV3M4uaJE4Ta6KAOdv # V+MVaJp1YXFTZPKtpjHO6d9pHQPZiG7NdC6QbnRGmsa48uNQrb6AfmLKDI1Lp31W # MogTaX5tZf+CZT9PSuvjOCLNAgMBAAGjggGCMIIBfjAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUj9RJL9zNrPcL10RZdMQIXZN7MG8w # VAYDVR0RBE0wS6RJMEcxLTArBgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJh # dGlvbnMgTGltaXRlZDEWMBQGA1UEBRMNMjMwMDEyKzQ1ODM4NjAfBgNVHSMEGDAW # gBRIbmTlUAXTgqoXNzcitW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8v # d3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIw # MTEtMDctMDguY3JsMGEGCCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDov # L3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDEx # XzIwMTEtMDctMDguY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIB # ACnXo8hjp7FeT+H6iQlV3CcGnkSbFvIpKYafgzYCFo3UHY1VHYJVb5jHEO8oG26Q # qBELmak6MTI+ra3WKMTGhE1sEIlowTcp4IAs8a5wpCh6Vf4Z/bAtIppP3p3gXk2X # 8UXTc+WxjQYsDkFiSzo/OBa5hkdW1g4EpO43l9mjToBdqEPtIXsZ7Hi1/6y4gK0P # mMiwG8LMpSn0n/oSHGjrUNBgHJPxgs63Slf58QGBznuXiRaXmfTUDdrvhRocdxIM # i8nXQwWACMiQzJSRzBP5S2wUq7nMAqjaTbeXhJqD2SFVHdUYlKruvtPSwbnqSRWT # GI8s4FEXt+TL3w5JnwVZmZkUFoioQDMMjFyaKurdJ6pnzbr1h6QW0R97fWc8xEIz # LIOiU2rjwWAtlQqFO8KNiykjYGyEf5LyAJKAO+rJd9fsYR+VBauIEQoYmjnUbTXM # SY2Lf5KMluWlDOGVh8q6XjmBccpaT+8tCfxpaVYPi1ncnwTwaPQvVq8RjWDRB7Pa # 8ruHgj2HJFi69+hcq7mWx5nTUtzzFa7RSZfE5a1a5AuBmGNRr7f8cNfa01+tiWjV # Kk1a+gJUBSP0sIxecFbVSXTZ7bqeal45XSDIisZBkWb+83TbXdTGMDSUFKTAdtC+ # r35GfsN8QVy59Hb5ZYzAXczhgRmk7NyE6jD0Ym5TKiW5MIIHejCCBWKgAwIBAgIK # YQ6Q0gAAAAAAAzANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNV # BAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jv # c29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlm # aWNhdGUgQXV0aG9yaXR5IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEw # OTA5WjB+MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE # BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYD # VQQDEx9NaWNyb3NvZnQgQ29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG # 9w0BAQEFAAOCAg8AMIICCgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+la # UKq4BjgaBEm6f8MMHt03a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc # 6Whe0t+bU7IKLMOv2akrrnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4D # dato88tt8zpcoRb0RrrgOGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+ # lD3v++MrWhAfTVYoonpy4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nk # kDstrjNYxbc+/jLTswM9sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6 # A4aN91/w0FK/jJSHvMAhdCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmd # X4jiJV3TIUs+UsS1Vz8kA/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL # 5zmhD+kjSbwYuER8ReTBw3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zd # sGbiwZeBe+3W7UvnSSmnEyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3 # T8HhhUSJxAlMxdSlQy90lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS # 4NaIjAsCAwEAAaOCAe0wggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRI # bmTlUAXTgqoXNzcitW2oynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTAL # BgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBD # uRQFTuHqp8cx0SOJNDBaBgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jv # c29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFf # MDNfMjIuY3JsMF4GCCsGAQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3 # dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFf # MDNfMjIuY3J0MIGfBgNVHSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEF # BQcCARYzaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1h # cnljcHMuaHRtMEAGCCsGAQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkA # YwB5AF8AcwB0AGEAdABlAG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn # 8oalmOBUeRou09h0ZyKbC5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7 # v0epo/Np22O/IjWll11lhJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0b # pdS1HXeUOeLpZMlEPXh6I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/ # KmtYSWMfCWluWpiW5IP0wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvy # CInWH8MyGOLwxS3OW560STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBp # mLJZiWhub6e3dMNABQamASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJi # hsMdYzaXht/a8/jyFqGaJ+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYb # BL7fQccOKO7eZS/sl/ahXJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbS # oqKfenoi+kiVH6v7RyOA9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sL # gOppO6/8MO0ETI7f33VtY5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtX # cVZOSEXAQsmbdlsKgEhr/Xmfwb1tbWrJUnMTDXpQzTGCFVowghVWAgEBMIGVMH4x # CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRt # b25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01p # Y3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTECEzMAAAGIr1LWuZJt6PkAAAAA # AYgwDQYJYIZIAWUDBAIBBQCggbAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQw # HAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIDJO # da3wv1z0EQ6QbgJ+Mr9ELnpN+auUcVGj2lUP2E61MEQGCisGAQQBgjcCAQwxNjA0 # oBSAEgBNAGkAYwByAG8AcwBvAGYAdKEcgBpodHRwczovL3d3dy5taWNyb3NvZnQu # Y29tIDANBgkqhkiG9w0BAQEFAASCAQB5svpUcXDrxkwUsOPYN95cyK1zR8NOjcEv # sZGImU2EPGxfXv4UNGOeUOzz8Tje3fzYns9164sFDj0C4S6gw2AF7WM3fERkinjj # 8W9WsFDVHAleP+qyIV5/tEXPhkdDTmW/rZK/Kl7k2MXs992LIHwqDjjhUmMSqVJ7 # Oc2uz8++5kX6oJB2hEOesUGBG4eJzYl5DxsdQpm9/IUqOlKAj2boPy1n03Z48PbW # 5iIEIWUWzLFqoDTsntmZdd2mH1jwTyLwhg4bnKT5RrFy96+Wof78IuFI91jdGxUt # Yhxf3NF9slo2duAKX4zIHizRtgznOX1pqInKHqZl+s7t1cRENfrRoYIS4jCCEt4G # CisGAQQBgjcDAwExghLOMIISygYJKoZIhvcNAQcCoIISuzCCErcCAQMxDzANBglg # hkgBZQMEAgEFADCCAVEGCyqGSIb3DQEJEAEEoIIBQASCATwwggE4AgEBBgorBgEE # AYRZCgMBMDEwDQYJYIZIAWUDBAIBBQAEIPMx5LKhkVe1/77JcxmQ66cATswBmjb6 # L81lFKG6TdS2AgZf2MoSJlEYEzIwMjEwMTE1MTEwNzQ5LjczNlowBIACAfSggdCk # gc0wgcoxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQH # EwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJTAjBgNV # BAsTHE1pY3Jvc29mdCBBbWVyaWNhIE9wZXJhdGlvbnMxJjAkBgNVBAsTHVRoYWxl # cyBUU1MgRVNOOjhBODItRTM0Ri05RERBMSUwIwYDVQQDExxNaWNyb3NvZnQgVGlt # ZS1TdGFtcCBTZXJ2aWNloIIOOTCCBPEwggPZoAMCAQICEzMAAAFLT7KmSNXkwlEA # AAAAAUswDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldh # c2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBD # b3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIw # MTAwHhcNMjAxMTEyMTgyNTU5WhcNMjIwMjExMTgyNTU5WjCByjELMAkGA1UEBhMC # VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV # BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFt # ZXJpY2EgT3BlcmF0aW9uczEmMCQGA1UECxMdVGhhbGVzIFRTUyBFU046OEE4Mi1F # MzRGLTlEREExJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2Uw # ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQChNnpQx3YuJr/ivobPoLtp # Q9egUFl8THdWZ6SAKIJdtP3L24D3/d63ommmjZjCyrQm+j/1tHDAwjQGuOwYvn79 # ecPCQfAB91JnEp/wP4BMF2SXyMf8k9R84RthIdfGHPXTWqzpCCfNWolVEcUVm8Ad # /r1LrikRO+4KKo6slDQJKsgKApfBU/9J7Rudvhw1rEQw0Nk1BRGWjrIp7/uWoUIf # R4rcl6U1utOiYIonC87PPpAJQXGRsDdKnVFF4NpWvMiyeuksn5t/Otwz82sGlne/ # HNQpmMzigR8cZ8eXEDJJNIZxov9WAHHj28gUE29D8ivAT706ihxvTv50ZY8W51ux # AgMBAAGjggEbMIIBFzAdBgNVHQ4EFgQUUqpqftASlue6K3LePlTTn01K68YwHwYD # VR0jBBgwFoAU1WM6XIoxkPNDe3xGG8UzaFqFbVUwVgYDVR0fBE8wTTBLoEmgR4ZF # aHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljVGlt # U3RhUENBXzIwMTAtMDctMDEuY3JsMFoGCCsGAQUFBwEBBE4wTDBKBggrBgEFBQcw # AoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNUaW1TdGFQ # Q0FfMjAxMC0wNy0wMS5jcnQwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEF # BQcDCDANBgkqhkiG9w0BAQsFAAOCAQEAFtq51Zc/O1AfJK4tEB2Nr8bGEVD5qQ8l # 8gXIQMrMZYtddHH+cGiqgF/4GmvmPfl5FAYh+gf/8Yd3q4/iD2+K4LtJbs/3v6mp # yBl1mQ4vusK65dAypWmiT1W3FiXjsmCIkjSDDsKLFBYH5yGFnNFOEMgL+O7u4osH # 42f80nc2WdnZV6+OvW035XPV6ZttUBfFWHdIbUkdOG1O2n4yJm10OfacItZ08fzg # MMqE+f/STgVWNCHbR2EYqTWayrGP69jMwtVD9BGGTWti1XjpvE6yKdO8H9nuRi3L # +C6jYntfaEmBTbnTFEV+kRx1CNcpSb9os86CAUehZU1aRzQ6CQ/pjzCCBnEwggRZ # oAMCAQICCmEJgSoAAAAAAAIwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNVBAYTAlVT # MRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQK # ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290 # IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTEwMDcwMTIxMzY1NVoXDTI1 # MDcwMTIxNDY1NVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x # EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv # bjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwggEiMA0G # CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCpHQ28dxGKOiDs/BOX9fp/aZRrdFQQ # 1aUKAIKF++18aEssX8XD5WHCdrc+Zitb8BVTJwQxH0EbGpUdzgkTjnxhMFmxMEQP # 8WCIhFRDDNdNuDgIs0Ldk6zWczBXJoKjRQ3Q6vVHgc2/JGAyWGBG8lhHhjKEHnRh # Z5FfgVSxz5NMksHEpl3RYRNuKMYa+YaAu99h/EbBJx0kZxJyGiGKr0tkiVBisV39 # dx898Fd1rL2KQk1AUdEPnAY+Z3/1ZsADlkR+79BL/W7lmsqxqPJ6Kgox8NpOBpG2 # iAg16HgcsOmZzTznL0S6p/TcZL2kAcEgCZN4zfy8wMlEXV4WnAEFTyJNAgMBAAGj # ggHmMIIB4jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU1WM6XIoxkPNDe3xG # G8UzaFqFbVUwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGG # MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZWy4/oolxiaNE9lJBb186a # GMQwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3Br # aS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3JsMFoGCCsG # AQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29t # L3BraS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcnQwgaAGA1UdIAEB # /wSBlTCBkjCBjwYJKwYBBAGCNy4DMIGBMD0GCCsGAQUFBwIBFjFodHRwOi8vd3d3 # Lm1pY3Jvc29mdC5jb20vUEtJL2RvY3MvQ1BTL2RlZmF1bHQuaHRtMEAGCCsGAQUF # BwICMDQeMiAdAEwAZQBnAGEAbABfAFAAbwBsAGkAYwB5AF8AUwB0AGEAdABlAG0A # ZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQAH5ohRDeLG4Jg/gXEDPZ2joSFv # s+umzPUxvs8F4qn++ldtGTCzwsVmyWrf9efweL3HqJ4l4/m87WtUVwgrUYJEEvu5 # U4zM9GASinbMQEBBm9xcF/9c+V4XNZgkVkt070IQyK+/f8Z/8jd9Wj8c8pl5SpFS # AK84Dxf1L3mBZdmptWvkx872ynoAb0swRCQiPM/tA6WWj1kpvLb9BOFwnzJKJ/1V # ry/+tuWOM7tiX5rbV0Dp8c6ZZpCM/2pif93FSguRJuI57BlKcWOdeyFtw5yjojz6 # f32WapB4pm3S4Zz5Hfw42JT0xqUKloakvZ4argRCg7i1gJsiOCC1JeVk7Pf0v35j # WSUPei45V3aicaoGig+JFrphpxHLmtgOR5qAxdDNp9DvfYPw4TtxCd9ddJgiCGHa # sFAeb73x4QDf5zEHpJM692VHeOj4qEir995yfmFrb3epgcunCaw5u+zGy9iCtHLN # HfS4hQEegPsbiSpUObJb2sgNVZl6h3M7COaYLeqN4DMuEin1wC9UJyH3yKxO2ii4 # sanblrKnQqLJzxlBTeCG+SqaoxFmMNO7dDJL32N79ZmKLxvHIa9Zta7cRDyXUHHX # odLFVeNp3lfB0d4wwP3M5k37Db9dT+mdHhk4L7zPWAUu7w2gUDXa7wknHNWzfjUe # CLraNtvTX4/edIhJEqGCAsswggI0AgEBMIH4oYHQpIHNMIHKMQswCQYDVQQGEwJV # UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE # ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1l # cmljYSBPcGVyYXRpb25zMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjo4QTgyLUUz # NEYtOUREQTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaIj # CgEBMAcGBSsOAwIaAxUAkToz97fseHxNOUSQ5O/bBVSF+e6ggYMwgYCkfjB8MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNy # b3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQUFAAIFAOOrfdkw # IhgPMjAyMTAxMTUxMDM1MDVaGA8yMDIxMDExNjEwMzUwNVowdDA6BgorBgEEAYRZ # CgQBMSwwKjAKAgUA46t92QIBADAHAgEAAgIUYTAHAgEAAgIRpTAKAgUA46zPWQIB # ADA2BgorBgEEAYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIBAAIDB6EgoQow # CAIBAAIDAYagMA0GCSqGSIb3DQEBBQUAA4GBAJCXc2xVUeGGUHLMDT4Tx7l/nMWQ # XmwVBcU21bwariOYWmzQyCXvQlJPcyvCUjOIqCPgDTzewUC51jCPXqqg57iLlwtR # BLRLILBe4F7HGyObNiz+UfoUf5FKDfZ4+2e88Qs0gW9KRU6l8aKU+xEHH5rZuJvy # l/w6jdGxKm1v1xHdMYIDDTCCAwkCAQEwgZMwfDELMAkGA1UEBhMCVVMxEzARBgNV # BAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jv # c29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAg # UENBIDIwMTACEzMAAAFLT7KmSNXkwlEAAAAAAUswDQYJYIZIAWUDBAIBBQCgggFK # MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAvBgkqhkiG9w0BCQQxIgQgZUn/ # N4K/pKdjY/VE9CpE0RfrLmmD0bns1pVO5GkjcfcwgfoGCyqGSIb3DQEJEAIvMYHq # MIHnMIHkMIG9BCBr9u6EInnsZYEts/Fj/rIFv0YZW1ynhXKOP2hVPUU5IzCBmDCB # gKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQH # EwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNV # BAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwAhMzAAABS0+ypkjV5MJR # AAAAAAFLMCIEIP7CBCmKpNAoqGlM17IIHxFF9eOEaSkIooocp3JUumoyMA0GCSqG # SIb3DQEBCwUABIIBAGrCZHGna2gfznLSf7Yq4lanh/nbedNUnzLbJUkRjts+B6FL # D3jLXUw+qApK9SPKejX1GVA7et3u2xh/Yf8SteRI72VhhqOeXvug8yy3WKjT2g2P # ATxchxMTNSn83z4nlRZjI6qQV6MxXT39dq9hoKW9KiBtiloqtzvaD4f6DOIU5SIq # B/fmE3K6ySMt94L2qbQyUA28exzdF1dPAnhByeDMDZu9yXnCHdbvcNhGkbY1O9vW # /i0Lpji58i5I5WgB6ERKlaNt6ySGcrmUScF7HxFUp2tqCaVk75pB/rVg1HX78ku0 # yO1iCO4Y0/sEHprlPrzNtZI5WK9kwyK1mO1LYKU= # SIG # End signature block |