Framework/Core/SVT/ADO/ADO.Organization.ps1
Set-StrictMode -Version Latest class Organization: ADOSVTBase { [PSObject] $ServiceEndPointsObj = $null [PSObject] $PipelineSettingsObj = $null [PSObject] $OrgPolicyObj = $null static $InstalledExtensionInfo hidden [PSObject] $allExtensionsObj; # This is used to fetch all extensions (shared+installed+requested) object so that it can be used in installed extension control where top publisher could not be computed. hidden [PSObject] $graphPermissions = @{hasGraphAccess = $false; graphAccessToken = $null}; # This is used to check user has graph permissions to compute the graph api operations. hidden $GuestMembers = @() hidden $AllUsersInOrg = @() #TODO: testing below line hidden [string] $SecurityNamespaceId; Organization([string] $organizationName, [SVTResource] $svtResource): Base($organizationName,$svtResource) { $this.GetOrgPolicyObject() $this.GetPipelineSettingsObj() $this.graphPermissions.hasGraphAccess = [IdentityHelpers]::HasGraphAccess(); if ($this.graphPermissions.hasGraphAccess) { $this.graphPermissions.graphAccessToken = [IdentityHelpers]::graphAccessToken } # If switch ALtControlEvaluationMethod is set as true in org policy, then evaluating control using graph API. If not then fall back to RegEx based evaluation. if ([string]::IsNullOrWhiteSpace([IdentityHelpers]::ALTControlEvaluationMethod)) { [IdentityHelpers]::ALTControlEvaluationMethod = "GraphThenRegEx" if ([Helpers]::CheckMember($this.ControlSettings, "ALTControlEvaluationMethod")) { if (($this.ControlSettings.ALtControlEvaluationMethod -eq "Graph")) { [IdentityHelpers]::ALTControlEvaluationMethod = "Graph" } elseif (($this.ControlSettings.ALtControlEvaluationMethod -eq "RegEx")) { [IdentityHelpers]::ALTControlEvaluationMethod = "RegEx" } } } } GetOrgPolicyObject() { try { $uri ="https://dev.azure.com/{0}/_settings/organizationPolicy?__rt=fps&__ver=2" -f $($this.OrganizationContext.OrganizationName); $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.OrganizationContext.OrganizationName); $orgUrl = "https://dev.azure.com/{0}" -f $($this.OrganizationContext.OrganizationName); $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.OrganizationContext.OrganizationName); $orgUrl = "https://dev.azure.com/{0}" -f $($this.OrganizationContext.OrganizationName); #$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.OrganizationContext.OrganizationName)] 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.OrganizationContext.OrganizationName)] 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.OrganizationContext.OrganizationName); $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.OrganizationContext.OrganizationName)) | 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.OrganizationContext.OrganizationName); $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.OrganizationContext.OrganizationName) | 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."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckSCALTForAdminMembers([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed try { if(($null -ne $this.ControlSettings) -and [Helpers]::CheckMember($this.ControlSettings, "Organization.GroupsToCheckForSCAltMembers")) { $adminGroupNames = @($this.ControlSettings.Organization.GroupsToCheckForSCAltMembers); if ($adminGroupNames.Count -gt 0) { #api call to get descriptor for organization groups. This will be used to fetch membership of individual groups later. $url = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $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.OrganizationContext.OrganizationName)) | 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.OrganizationContext.OrganizationName) $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.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.OrganizationContext.OrganizationName) $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.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.Count -gt 0) { $useGraphEvaluation = $false $useRegExEvaluation = $false if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "GraphThenRegEx") { if ($this.graphPermissions.hasGraphAccess){ $useGraphEvaluation = $true } else { $useRegExEvaluation = $true } } if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "Graph" -or $useGraphEvaluation) { if ($this.graphPermissions.hasGraphAccess) { $allAdmins = [IdentityHelpers]::DistinguishAltAndNonAltAccount($allAdminMembers) $SCMembers = $allAdmins.altAccount $nonSCMembers = $allAdmins.nonAltAccount $nonSCCount = $nonSCMembers.Count $SCCount = $SCMembers.Count if ($nonSCCount -gt 0) { $nonSCMembers = $nonSCMembers | Select-Object name,mailAddress,groupName $stateData = @(); $stateData += $nonSCMembers $controlResult.AddMessage([VerificationResult]::Failed, "`nCount of non-ALT accounts with admin privileges: $nonSCCount"); $controlResult.AddMessage("List of non-ALT accounts: ", $($stateData | Format-Table -AutoSize | Out-String)); $controlResult.SetStateData("List of non-ALT accounts: ", $stateData); $controlResult.AdditionalInfo += "Count of non-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("`nCount of ALT accounts with admin privileges: $SCCount"); $controlResult.AdditionalInfo += "Count of ALT accounts with admin privileges: " + $SCCount; $controlResult.AddMessage("List of ALT accounts: ", $($SCData | Format-Table -AutoSize | Out-String)); } } else { $controlResult.AddMessage([VerificationResult]::Error, "The signed-in user identity does not have graph permission."); } } if ([IdentityHelpers]::ALTControlEvaluationMethod -eq "RegEx" -or $useRegExEvaluation) { 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.Count $SCMembers = @(); $SCMembers += $allAdminMembers | Where-Object { $_.mailAddress -match $matchToSCAlt } $SCCount = $SCMembers.Count if ($nonSCCount -gt 0) { $nonSCMembers = $nonSCMembers | Select-Object name,mailAddress,groupName $stateData = @(); $stateData += $nonSCMembers $controlResult.AddMessage([VerificationResult]::Failed, "`nCount of non-ALT accounts with admin privileges: $nonSCCount"); $controlResult.AddMessage("List of non SC-ALT accounts: ", $($stateData | Format-Table -AutoSize | Out-String)); $controlResult.SetStateData("List of non SC-ALT accounts: ", $stateData); $controlResult.AdditionalInfo += "Count 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("`nCount of ALT accounts with admin privileges: $SCCount"); $controlResult.AdditionalInfo += "Count of ALT accounts with admin privileges: " + $SCCount; $controlResult.AddMessage("List of ALT accounts: ", $($SCData | Format-Table -AutoSize | Out-String)); } } 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."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckAADConfiguration([ControlResult] $controlResult) { try { $apiURL = "https://dev.azure.com/{0}/_settings/organizationAad?__rt=fps&__ver=2" -f $($this.OrganizationContext.OrganizationName); $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."); $controlResult.LogException($_) } 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."); $controlResult.LogException($_) } } return $controlResult } hidden [ControlResult] CheckExternalUserPolicy([ControlResult] $controlResult) { $controlResult.VerificationResult = [VerificationResult]::Failed if([Helpers]::CheckMember($this.OrgPolicyObj,"user")) { $userPolicyObj = $this.OrgPolicyObj.user; $guestAuthObj = @($userPolicyObj | Where-Object {$_.Policy.Name -eq "Policy.DisallowAadGuestUserAccess"}) if($guestAuthObj.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."); if($this.GuestMembers.Count -eq 0) { $this.FetchGuestMembersInOrg() } $totalGuestCount = $this.GuestMembers.Count if($totalGuestCount -gt 0) { $controlResult.AddMessage("`nCount of guest users in the organization: $($totalGuestCount)"); $controlResult.AdditionalInfo += "Count of guest users in the organization: " + $totalGuestCount; } } } 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=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $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 $extCount = ($extensionList | Measure-Object ).Count; if($extCount -gt 0) { $controlResult.AddMessage([VerificationResult]::Verify, "`nReview the list of installed extensions for your org: "); $controlResult.AddMessage("No. of installed extensions: " + $extCount); $controlResult.AdditionalInfo += "No. of installed extensions: " + $extCount; if([AzSKRoot]::IsDetailedScanRequired -eq $false) { #if([Helpers]::CheckMember($this.ControlSettings, "Organization.KnownExtensionPublishersId")) #{$knownExtPublishersId = $this.ControlSettings.Organization.KnownExtensionPublishersId;} $knownExtPublishers = $this.ControlSettings.Organization.KnownExtensionPublishers; $knownExtensions = @(); #$knownExtensions += $extensionList | Where-Object {$_.publisherId -in $KnownExtPublishersId} $knownExtensions += $extensionList | Where-Object {$_.publisherName -in $knownExtPublishers} $knownCount = ($knownExtensions | Measure-Object).Count $unKnownExtensions = @(); #Publishers not Known by Microsoft #$unKnownExtensions += $extensionList | Where-Object {$_.publisherId -notin $KnownExtPublishersId} $unKnownExtensions += $extensionList | Where-Object {$_.publisherName -notin $knownExtPublishers} $unKnownCount = ($unKnownExtensions | Measure-Object).Count $controlResult.AddMessage("`nNote: The following publishers are considered as 'known publishers': `n`t[$($knownExtPublishers -join ', ')]"); if($unKnownCount -gt 0){ $controlResult.AddMessage("`nNo. of extensions (from publishers not in 'known publishers' list): $unKnownCount"); $controlResult.AdditionalInfo += "No. of installed extensions (from publishers not in 'known publishers' list): " + $unKnownCount; $controlResult.AddMessage("`nExtension details: ") $display = ($unKnownExtensions | FT ExtensionName, publisherId, publisherName, Version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) $controlResult.AdditionalInfo += "Installed extensions (from 'unknown publishers'): " + [JsonHelper]::ConvertToJsonCustomCompressed($unKnownExtensions); } if($knownCount -gt 0){ $controlResult.AddMessage("`nNo. of extensions (from publishers in the 'known publishers' list): $knownCount"); $controlResult.AdditionalInfo += "No. of extensions (from publishers in the 'known publishers' list): " + $knownCount; $controlResult.AddMessage("`nExtension details: ") $display = ($knownExtensions|FT ExtensionName, publisherId, publisherName, Version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } $stateData = @{ known_Extensions = @(); unKnown_Extensions = @(); }; $stateData.known_Extensions += $knownExtensions $stateData.unKnown_Extensions += $unKnownExtensions $controlResult.SetStateData("List of installed extensions: ", $stateData); } ## Deep scan start if([AzSKRoot]::IsDetailedScanRequired -eq $true) { $this.PublishCustomMessage("You have requested for detailed scan, it will take few minutes..`n",[MessageType]::Warning); $isKnownPublishersPropertyPresent = $false $islastUpdatedPropertyPresent = $false $isCriticalScopesPropertyPresent = $false $isNonProdIndicatorsPropertyPresent = $false if($null -ne $this.ControlSettings) { if([Helpers]::CheckMember($this.ControlSettings, "Organization.KnownExtensionPublishers")) { $knownExtPublishers = $this.ControlSettings.Organization.KnownExtensionPublishers; $isKnownPublishersPropertyPresent = $true } else { $knownExtPublishers = @() } if([Helpers]::CheckMember($this.ControlSettings, "Organization.ExtensionsLastUpdatedInYears")) { $extensionsLastUpdatedInYears = $this.ControlSettings.Organization.ExtensionsLastUpdatedInYears $islastUpdatedPropertyPresent = $true } else { $extensionsLastUpdatedInYears = 2 ##Default value } if([Helpers]::CheckMember($this.ControlSettings, "Organization.ExtensionCriticalScopes") ) { $extensionCriticalScopes=$this.ControlSettings.Organization.ExtensionCriticalScopes; $isCriticalScopesPropertyPresent = $true } else{ $extensionCriticalScopes = @() } if([Helpers]::CheckMember($this.ControlSettings, "Organization.NonProductionExtensionIndicators")) { $nonProductionExtensionIndicators =$this.ControlSettings.Organization.NonProductionExtensionIndicators; $isNonProdIndicatorsPropertyPresent = $true } else { $nonProductionExtensionIndicators = @() } $ExemptedExtensionNames = @() if([Helpers]::CheckMember($this.ControlSettings, "Organization.ExemptedExtensionNames")) { $ExemptedExtensionNames += $this.ControlSettings.Organization.ExemptedExtensionNames; } $controlResult.AddMessage([Constants]::HashLine) if( !($isKnownPublishersPropertyPresent -and $islastUpdatedPropertyPresent -and $isCriticalScopesPropertyPresent -and $isNonProdIndicatorsPropertyPresent)) { $controlResult.AddMessage("***Note: Some settings are not present in the policy configuration.***") } $controlResult.AddMessage("`nNote: Apart from this LOG, a combined listing of all extensions and their security sensitive attributes has been output to the '$($this.ResourceContext.ResourceName)"+"_ExtensionInfo.CSV' file in the current folder. Columns with value as 'Unavailable' indicate that data was not available.") $infotable = [ordered] @{ "KnownPublisher" = "Yes/No [if extension is from [$($knownExtPublishers -join ', ')]]"; "Too Old (> $($extensionsLastUpdatedInYears)year(s))" = "Yes/No [if extension has not been updated by publishers for more than [$extensionsLastUpdatedInYears] year(s)]"; "SensitivePermissions" = "Lists if any permissions requested by extension are in the sensitive permissions list. (See list below for the full list of permissions considered to be sensitive.)"; "NonProd (GalleryFlag)" = "Yes/No [if the gallery flags in the manifest mention 'preview']"; "NonProd (ExtensionName)" = "Yes/No [if extension name indicates [$($nonProductionExtensionIndicators -join ', ')]]"; "TopPublisher" = "Yes/No [if extension's publisher has 'Top Publisher' certification]"; "PrivateVisibility" = "Yes/No [if extension has been shared privately with the org]" ; "Score" = "Secure score of extension. (See further below for the scoring scheme.) " } $scoretable = @( New-Object psobject -Property $([ordered] @{"Parameter"="'Top Publisher' certification";"Score (if Yes)"="+10"; "Score (if No)" = "0"}); New-Object psobject -Property $([ordered] @{"Parameter"="Known publishers";"Score (if Yes)"="+10"; "Score (if No)" = "0"}); New-Object psobject -Property $([ordered] @{"Parameter"="Too Old ( x years )";"Score (if Yes)"="-5*(No. of years when extension was last published before threshhold)"; "Score (if No)" = "0"}) New-Object psobject -Property $([ordered] @{"Parameter"="Sensitive permissions(n)";"Score (if Yes)"="-5*(No. of sensitive permmissions found)"; "Score (if No)" = "0"}); New-Object psobject -Property $([ordered] @{"Parameter"="NonProd (GalleryFlag)";"Score (if Yes)"="-10"; "Score (if No)" = "+10"}) New-Object psobject -Property $([ordered] @{"Parameter"="NonProd (ExtensionName)";"Score (if Yes)"="-10"; "Score (if No)" = "+10"}) New-Object psobject -Property $([ordered] @{"Parameter"="Private visibility";"Score (if Yes)"="-10"; "Score (if No)" = "+10"}) New-Object psobject -Property $([ordered] @{"Parameter"="Average Rating ";"Score (if Yes)"="+2*(Marketplace average rating)"; "Score (if No)" = "0"}) ) | Format-Table -AutoSize | Out-String -Width $ftWidth $helperTable = $infotable.keys | Select @{l='Column';e={$_}},@{l='Interpretation';e={$infotable.$_}} | Format-Table -AutoSize | Out-String -Width $ftWidth $controlResult.AddMessage($helperTable) $controlResult.AddMessage("The following extension permissions are considered sensitive:") if(!$isCriticalScopesPropertyPresent) { $controlResult.AddMessage("***'Extension critical scopes' setting is not present in the policy configuration.***") } $controlResult.AddMessage($extensionCriticalScopes) $controlResult.AddMessage("`nThe following scheme is used for assigning secure score:") $controlResult.AddMessage($scoretable) $combinedTable=@() $knownExtensions=@() $unKnownExtensions = @() $staleExtensionList = @() $extensionListWithCriticalScopes = @() $extensionListWithNonProductionExtensionIndicators=@() $privateExtensions = @() $nonProdExtensions = @() $topPublisherExtensions = @() [Organization]::InstalledExtensionInfo = @() $allInstalledExtensions = @() # This variable gets all installed extensions details from $allExtensionsObj $date = Get-Date $thresholdDate = $date.AddYears(-$extensionsLastUpdatedInYears) $extensionList | ForEach-Object { $extensionInfo="" | Select-Object ExtensionName,PublisherId,PublisherName,Version,KnownPublisher,TooOld,LastPublished,SensitivePermissions,Scopes,NonProdByName,Preview,TopPublisher,PrivateVisibility,MarketPlaceAverageRating,Score,NoOfInstalls,MaxScore $extensionInfo.ExtensionName = $_.extensionName $extensionInfo.PublisherId = $_.publisherId $extensionInfo.PublisherName = $_.publisherName $extensionInfo.Version = $_.version $extensionInfo.LastPublished = ([datetime] $_.lastPublished).ToString("MM-dd-yyyy") $extensionInfo.Score = 0 $extensionInfo.MaxScore = 0 # Checking for known publishers $extensionInfo.MaxScore += 10 # Known publisher score if($_.publisherName -in $knownExtPublishers) { $extensionInfo.KnownPublisher = "Yes" $knownExtensions += $_ $extensionInfo.Score += 10 } else { $extensionInfo.KnownPublisher = "No" $unKnownExtensions += $_ } # Checking whether extension is too old or not if(([datetime] $_.lastPublished) -lt $thresholdDate) { $staleExtensionList += $_ $extensionInfo.TooOld = "Yes" $diffInYears = [Math]::Round(($thresholdDate - ([datetime] $_.lastPublished)).Days/365) $extensionInfo.Score -= $diffInYears * (5) } else { $extensionInfo.TooOld = "No" } # Checking whether extension have sensitive permissions $riskyScopes = @($_.scopes | ? {$_ -in $extensionCriticalScopes}) if($riskyScopes.count -gt 0) { $extensionListWithCriticalScopes += $_ $extensionInfo.SensitivePermissions = ($riskyScopes -join ',' ) $extensionInfo.Score -= $riskyScopes.Count * 5 } else { $extensionInfo.SensitivePermissions = "None" } # Checking whether extension name comes under exempted extension name or non prod indicators $extensionInfo.MaxScore += 10 # Score for extension Name not in non prod indicators if($_.extensionName -in $ExemptedExtensionNames) { $extensionInfo.NonProdByName = "No" $extensionInfo.Score += 10 } else { $isExtensionNameInIndicators = $false for($j=0;$j -lt $nonProductionExtensionIndicators.Count;$j++) { if( $_.extensionName -match $nonProductionExtensionIndicators[$j]) { $isExtensionNameInIndicators = $true break } } if($isExtensionNameInIndicators) { $extensionInfo.NonProdByName = "Yes" $extensionListWithNonProductionExtensionIndicators += $_ $extensionInfo.Score -= 10 } else { $extensionInfo.NonProdByName = "No" $extensionInfo.Score += 10 } } $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)' } ] } ], 'flags': 870 }" $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 $extensionInfo.MaxScore += 10 # Private visibility score $extensionInfo.MaxScore += 10 # Preview in Gallery flags score $extensionInfo.MaxScore += 10 # Marketplace average rating score $extensionInfo.MaxScore += 10 # Top publisher certification score if([Helpers]::CheckMember($responseobject.results[0], "extensions") -eq $false ) { $extensionInfo.PrivateVisibility = "Yes" $extensionInfo.Preview = "Unavailable" $extensionInfo.Score -= 10 if($null -eq $this.allExtensionsObj) { $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $orgURL="https://dev.azure.com/{0}/_settings/extensions" -f $($this.OrganizationContext.OrganizationName); $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 $this.allExtensionsObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); } if(($allInstalledExtensions.Count -eq 0) -and [Helpers]::CheckMember($this.allExtensionsObj[0],"dataProviders") -and $this.allExtensionsObj.dataProviders.'ms.vss-extmgmt-web.extensionManagmentHub-collection-data-provider') { # Using sharedExtension Object so that we can get details of all extensions from shared extension api and later use it to compute top publisher for installed extension $allInstalledExtensions = $this.allExtensionsObj[0].dataProviders.'ms.vss-extmgmt-web.extensionManagmentHub-collection-data-provider'.installedextensions } $currentExtension = $_ #This refernce variable contains current private extension's top publisher details $refVar = ($allInstalledExtensions | Where-Object {($_.extensionId -eq $currentExtension.extensionId) -and ($_.publisherId -eq $currentExtension.publisherId) }) # if refvar is null then making Unavailable for top publisher if($refVar) { if($refVar.isCertifiedPublisher) { $extensionInfo.TopPublisher = "Yes" $extensionInfo.Score += 10 } else { $extensionInfo.TopPublisher = "No" } } else { $extensionInfo.TopPublisher = "Unavailable" } $privateExtensions += $_ } else { $extensionInfo.PrivateVisibility = "No" $extensionInfo.Score += 10 $extensionflags=$responseobject.results[0].extensions.flags if($extensionflags -match 'Preview') { $extensionInfo.Preview = "Yes" $nonProdExtensions += $_ $extensionInfo.Score -= 10 } else { $extensionInfo.Preview = "No" $extensionInfo.Score += 10 } $publisherFlags = $responseobject.results[0].extensions.publisher.flags if($publisherFlags -match "Certified") { $extensionInfo.TopPublisher = "Yes" $topPublisherExtensions += $_ $extensionInfo.Score += 10 } else { $extensionInfo.TopPublisher = "No" } } if([Helpers]::CheckMember($responseObject.results[0].extensions,"statistics")) { $statistics = $responseObject.results[0].extensions.statistics $extensionInfo.NoOfInstalls = 0 $statistics | ForEach-Object { if($_.statisticName -eq "averagerating") { $extensionInfo.MarketPlaceAverageRating = [Math]::Round($_.Value,1) $extensionInfo.Score += [Math]::Round($extensionInfo.MarketPlaceAverageRating*2) } if($_.statisticName -eq "install") { $extensionInfo.NoOfInstalls += $_.Value } if($_.statisticName -eq "onpremDownloads") { $extensionInfo.NoOfInstalls += $_.Value } } if($null -eq $extensionInfo.MarketPlaceAverageRating) { $extensionInfo.MarketPlaceAverageRating = 0 } } else { $extensionInfo.MarketPlaceAverageRating = "Unavailable" $extensionInfo.NoOfInstalls = "Unavailable" } $combinedTable += $extensionInfo } $MaxScore = $combinedTable[0].MaxScore $controlResult.AddMessage("Note: Using this scheme an extension can get a maximum secure score of $MaxScore.`n") $controlResult.AddMessage([Constants]::HashLine) $controlResult.AddMessage([Constants]::SingleDashLine +"`nLooking for extensions from known publishers`n"+[Constants]::SingleDashLine) $controlResult.AddMessage("`nNote: The following are considered as 'known' publishers: `n`t[$($knownExtPublishers -join ', ')]"); if(!$IsKnownPublishersPropertyPresent) { $controlResult.AddMessage("***'Known publisher' setting is not present in the policy configuration.***") } $unKnownCount = ($unKnownExtensions | Measure-Object).Count if($unKnownCount -gt 0){ $controlResult.AddMessage("`nNo. of extensions (from publishers not in 'known publishers' list): $unKnownCount"); $controlResult.AdditionalInfo += "No. of installed extensions (from publishers not in 'known publishers' list): " + $unKnownCount; $controlResult.AddMessage("`nExtension details (from publishers not in 'known publishers' list): ") $display = ($unKnownExtensions | FT ExtensionName, publisherId, publisherName, Version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) $controlResult.AdditionalInfo += "Installed extensions (from unknown publishers): " + [JsonHelper]::ConvertToJsonCustomCompressed($unKnownExtensions); } $knownCount = ($knownExtensions | Measure-Object).Count if($knownCount -gt 0){ $controlResult.AddMessage("`nNo. of extensions (from publishers in the 'known publishers' list): $knownCount"); $controlResult.AdditionalInfo += "No. of extensions (from publishers in the 'known publishers' list): " + $knownCount; $controlResult.AddMessage("`nExtension details (from publishers in the 'known publishers' list): ") $display = ($knownExtensions|FT ExtensionName, publisherId, publisherName, Version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } $stateData = @{ known_Extensions = @(); unKnown_Extensions = @(); }; $stateData.known_Extensions += $knownExtensions $stateData.unKnown_Extensions += $unKnownExtensions $controlResult.SetStateData("List of installed extensions: ", $stateData); if($staleExtensionList.count -gt 0) { $controlResult.AddMessage([Constants]::HashLine) $controlResult.AddMessage([Constants]::SingleDashLine +"`nLooking for extensions that have not been updated by publishers for more than [$extensionsLastUpdatedInYears] years...`n" +[Constants]::SingleDashLine) if(!$islastUpdatedPropertyPresent) { $controlResult.AddMessage("***'Last Updated' setting is not present in the policy configuration.***") } $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, @{Name = "lastPublished (MM-dd-yyyy)"; Expression = { ([datetime] $_.lastPublished).ToString("MM-dd-yyyy")} }, PublisherId, PublisherName, version -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } if($extensionListWithCriticalScopes.count -gt 0) { $controlResult.AddMessage([Constants]::HashLine) $controlResult.AddMessage([Constants]::SingleDashLine + "`nLooking for extensions that have sensitive access permissions...`n" + [Constants]::SingleDashLine) if(!$isCriticalScopesPropertyPresent) { $controlResult.AddMessage("***'Extension critical scopes' setting is not present in the policy configuration.***") } $controlResult.AddMessage("Note: The following permissions are considered sensitive: `n`t[$($extensionCriticalScopes -join ', ')]") $controlResult.AddMessage("`nNo. of extensions that have sensitive access permissions: "+ $extensionListWithCriticalScopes.count) $controlResult.AddMessage("`nExtension details (extensions that have sensitive access permissions): ") $display= ($extensionListWithCriticalScopes | FT ExtensionName, scopes, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth) $controlResult.AddMessage($display) } if($extensionListWithNonProductionExtensionIndicators.count -gt 0) { $controlResult.AddMessage([Constants]::HashLine) $controlResult.AddMessage([Constants]::SingleDashLine+"`nLooking for extensions that are not production ready...`n"+[Constants]::SingleDashLine) if(!$isNonProdIndicatorsPropertyPresent) { $controlResult.AddMessage("***'Non-production extension indicators' setting is not present in the policy configuration.***") } $controlResult.AddMessage("Note: This checks for extensions with words [$($nonProductionExtensionIndicators -join ', ')] in extension names.") $controlResult.AddMessage("`nNo. of non-production extensions (based on name): "+ $extensionListWithNonProductionExtensionIndicators.count) $controlResult.AddMessage("`nExtension details (non-production extensions (based on name)): ") $controlResult.AddMessage( ($extensionListWithNonProductionExtensionIndicators | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth)) } if($nonProdExtensions.count -gt 0) { $controlResult.AddMessage([Constants]::HashLine) $controlResult.AddMessage([Constants]::SingleDashLine+"`nLooking for extensions that are marked 'Preview' via Gallery flags...`n"+[Constants]::SingleDashLine) $controlResult.AddMessage("`nNo. of installed extensions marked as 'Preview' via Gallery flags: "+ $nonProdExtensions.count); $controlResult.AddMessage("`nExtension details (installed extensions which are marked as 'Preview' via Gallery flags): ") $controlResult.AddMessage(($nonProdExtensions | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth)); } if($topPublisherExtensions.count -gt 0) { $controlResult.AddMessage([Constants]::HashLine) $controlResult.AddMessage([Constants]::SingleDashLine+"`nLooking for extensions that are from publishers with a 'Top Publisher' certification...`n"+[Constants]::SingleDashLine); $controlResult.AddMessage("`nNo. of installed extensions from 'Top Publishers': "+$topPublisherExtensions.count); $controlResult.AddMessage("`nExtension details (installed extensions from 'Top Publishers'): ") $controlResult.AddMessage(($topPublisherExtensions | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth) ); } if($privateExtensions.count -gt 0) { $controlResult.AddMessage([Constants]::HashLine) $controlResult.AddMessage([Constants]::SingleDashLine+"`nLooking for extensions that have 'private' visibility for the org...`n"+[Constants]::SingleDashLine); $controlResult.AddMessage("`nNo. of installed extensions with 'private' visibility: "+$privateExtensions.count); $controlResult.AddMessage("`nExtension details (installed extensions with 'private' visibility): ") $controlResult.AddMessage(($privateExtensions | FT ExtensionName, PublisherId, PublisherName -AutoSize | Out-String -Width $ftWidth)); } [Organization]::InstalledExtensionInfo = $combinedTable } } ## 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."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] ValidateSharedExtensions([ControlResult] $controlResult) { try { if($null -eq $this.allExtensionsObj) { $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $orgURL="https://dev.azure.com/{0}/_settings/extensions" -f $($this.OrganizationContext.OrganizationName); $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 $this.allExtensionsObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); } if([Helpers]::CheckMember($this.allExtensionsObj[0],"dataProviders") -and $this.allExtensionsObj.dataProviders.'ms.vss-extmgmt-web.extensionManagmentHub-collection-data-provider') { $sharedExtensions = $this.allExtensionsObj[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."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckGuestIdentities([ControlResult] $controlResult) { try { if($this.GuestMembers.Count -eq 0) { $this.FetchGuestMembersInOrg() } $guestUsers = @($this.GuestMembers) if($guestUsers.Count -gt 0) { $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.1-preview.3" -f $($this.OrganizationContext.OrganizationName), $($guestUser.Id); $projectEntitlements = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $userProjectEntitlements = $projectEntitlements[0].projectEntitlements } catch { $userProjectEntitlements = "Could not fetch project entitlement details of the user." $controlResult.LogException($_) } 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."); $controlResult.LogException($_) } 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.OrganizationContext.OrganizationName); 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."); $controlResult.LogException($_) } 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&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName), $topInActiveUsers; $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); if($responseObj.Count -gt 0) { $inactiveUsers = @() $inactivityThresholdInDays = $this.ControlSettings.Organization.InActiveUserActivityLogsPeriodInDays $thresholdDate = (Get-Date).AddDays(-$($inactivityThresholdInDays)) $responseObj[0].items | ForEach-Object { if([datetime]::Parse($_.lastAccessedDate) -lt $thresholdDate) { $inactiveUsers+= $_ } } if($inactiveUsers.Count -gt 0) { $controlResult.AddMessage("Found $($inactiveUsers.Count) inactive for last $($inactivityThresholdInDays) days.") 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 users found to be inactive for last $($inactivityThresholdInDays) days.") } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users found in the org."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of users in the organization."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckDisconnectedIdentities([ControlResult] $controlResult) { try { $apiURL = "https://dev.azure.com/{0}/_apis/OrganizationSettings/DisconnectedUser" -f $($this.OrganizationContext.OrganizationName); $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."); $controlResult.LogException($_) } return $controlResult; } hidden [ControlResult] CheckRBACAccess([ControlResult] $controlResult) { <# This control has been currently removed from control JSON file. { "ControlID": "ADO_Organization_AuthZ_Min_RBAC_Access", "Description": "All teams/groups must be granted minimum required permissions in your organization.", "Id": "Organization200", "ControlSeverity": "High", "Automated": "No", "MethodName": "CheckRBACAccess", "Rationale": "Granting minimum access by leveraging RBAC feature ensures that users are granted just enough permissions to perform their tasks. This minimizes exposure of the resources in case of user/service account compromise.", "Recommendation": "Go to Organization Settings --> Permissions --> Select team/group --> Validate Permissions", "Tags": [ "SDL", "TCP", "Manual", "AuthZ", "RBAC" ], "Enabled": true } #> $url= "https://vssps.dev.azure.com/{0}/_apis/graph/groups?api-version=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $groupsObj = [WebRequestHelper]::InvokeGetWebRequest($url); $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?top=50&filter=&sortOption=lastAccessDate+ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName); $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=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $groupsObj = [WebRequestHelper]::InvokeGetWebRequest($url); $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview" -f $($this.OrganizationContext.OrganizationName); $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.OrganizationContext.OrganizationName)/_settings/groups?subjectDescriptor=$($descriptor)"; $usersObj = [WebRequestHelper]::InvokePostWebRequest($apiURL,$inputbody); if([Helpers]::CheckMember($usersObj.dataProviders.'ms.vss-admin-web.org-admin-members-data-provider', "identities")) { $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=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $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."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckMinPCACount([ControlResult] $controlResult) { $TotalPCAMembers=0 $PCAMembers = @() $PCAMembers += [AdministratorHelper]::GetTotalPCAMembers($this.OrganizationContext.OrganizationName) $TotalPCAMembers = ($PCAMembers| Measure-Object).Count $controlResult.AddMessage("There are a total of $TotalPCAMembers Project Collection Administrators in your organization.") if ($this.graphPermissions.hasGraphAccess) { $SvcAndHumanAccounts = [IdentityHelpers]::DistinguishHumanAndServiceAccount($PCAMembers, $this.OrganizationContext.OrganizationName) $HumanAcccountCount = ($SvcAndHumanAccounts.humanAccount | Measure-Object).Count if($HumanAcccountCount -lt $this.ControlSettings.Organization.MinPCAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of human administrators configured are less than the minimum required administrators count: $($this.ControlSettings.Organization.MinPCAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of human 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: ") $controlResult.AdditionalInfo += "Total number of Project Collection Administrators: " + $TotalPCAMembers; } if (($SvcAndHumanAccounts.humanAccount | Measure-Object).Count -gt 0) { $humanAccounts = $SvcAndHumanAccounts.humanAccount | Select-Object displayName, mailAddress $controlResult.AddMessage("`nHuman Administrators: $(($humanAccounts| Measure-Object).Count)", $humanAccounts) $controlResult.SetStateData("List of human Project Collection Administrators: ",$humanAccounts) } if (($SvcAndHumanAccounts.serviceAccount | Measure-Object).Count -gt 0) { $svcAccounts = $SvcAndHumanAccounts.serviceAccount | Select-Object displayName, mailAddress $controlResult.AddMessage("`nService Account Administrators: $(($svcAccounts| Measure-Object).Count)", $svcAccounts) $controlResult.SetStateData("List of service account Project Collection Administrators: ",$svcAccounts) } } else { $PCAMembers = $PCAMembers | Select-Object displayName,mailAddress 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.OrganizationContext.OrganizationName) $TotalPCAMembers = ($PCAMembers| Measure-Object).Count $controlResult.AddMessage("There are a total of $TotalPCAMembers Project Collection Administrators in your organization.") if ($this.graphPermissions.hasGraphAccess) { $SvcAndHumanAccounts = [IdentityHelpers]::DistinguishHumanAndServiceAccount($PCAMembers, $this.OrganizationContext.OrganizationName) $HumanAcccountCount = ($SvcAndHumanAccounts.humanAccount | Measure-Object).Count if($HumanAcccountCount -gt $this.ControlSettings.Organization.MaxPCAMembersPermissible){ $controlResult.AddMessage([VerificationResult]::Failed,"Number of human administrators configured are more than the approved limit: $($this.ControlSettings.Organization.MaxPCAMembersPermissible)"); } else{ $controlResult.AddMessage([VerificationResult]::Passed,"Number of human administrators configured are within than the approved limit: $($this.ControlSettings.Organization.MaxPCAMembersPermissible)"); } if($TotalPCAMembers -gt 0){ $controlResult.AddMessage("Verify the following Project Collection Administrators: ") $controlResult.AdditionalInfo += "Total number of Project Collection Administrators: " + $TotalPCAMembers; } if (($SvcAndHumanAccounts.humanAccount | Measure-Object).Count -gt 0) { $humanAccounts = $SvcAndHumanAccounts.humanAccount | Select-Object displayName, mailAddress $controlResult.AddMessage("`nHuman Administrators: $(($humanAccounts| Measure-Object).Count)", $humanAccounts) $controlResult.SetStateData("List of human Project Collection Administrators: ",$humanAccounts) } if (($SvcAndHumanAccounts.serviceAccount | Measure-Object).Count -gt 0) { $svcAccounts = $SvcAndHumanAccounts.serviceAccount | Select-Object displayName, mailAddress $controlResult.AddMessage("`nService Account Administrators: $(($svcAccounts| Measure-Object).Count)", $svcAccounts) $controlResult.SetStateData("List of service account Project Collection Administrators: ",$svcAccounts) } } else { $PCAMembers = $PCAMembers | Select-Object displayName,mailAddress 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=6.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $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."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] ValidateRequestedExtensions([ControlResult] $controlResult) { $apiURL = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" -f $($this.OrganizationContext.OrganizationName); $orgURL="https://dev.azure.com/{0}/_settings/extensions" -f $($this.OrganizationContext.OrganizationName); $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."); $controlResult.LogException($_) } return $controlResult } hidden [ControlResult] CheckInactiveGuestUsers([ControlResult] $controlResult) { try { $controlResult.VerificationResult = [VerificationResult]::Failed if($this.GuestMembers.Count -eq 0) { $this.FetchGuestMembersInOrg() } $users = @($this.GuestMembers) if($users.Count -gt 0) { $inactiveGuestUsers = @() $GuestUserInactivePeriodInDays = 90; if ([Helpers]::CheckMember($this.ControlSettings.Organization, "GuestUserInactivePeriodInDays") -and (-not [String]::IsNullOrEmpty($this.ControlSettings.Organization.GuestUserInactivePeriodInDays))) { $GuestUserInactivePeriodInDays = $this.ControlSettings.Organization.GuestUserInactivePeriodInDays } $thresholdDate = (Get-Date).AddDays(-$($GuestUserInactivePeriodInDays)) $users | ForEach-Object { if([datetime]::Parse($_.lastAccessedDate) -lt $thresholdDate) { $inactiveGuestUsers+= $_ } } $inactiveGuestUsersCount = $inactiveGuestUsers.Count $controlResult.AddMessage("`nFound total $($users.Count) guest users."); if($inactiveGuestUsersCount -gt 0) { #If user account created and was never active, in this case lastaccessdate is default 01-01-0001 $inactiveUsers = ($inactiveGuestUsers | Select-Object -Property @{Name="Name"; Expression = {$_.User.displayName}},@{Name="Email"; 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="Email"; Expression = {$_.Email}}) #Can Expect drift, are there any org level attestations? #$inactiveUsersCount = ($inactiveUsers | Measure-Object).Count $controlResult.AddMessage([VerificationResult]::Failed,"Count of inactive guest users in the organization: $($inactiveGuestUsersCount)"); $controlResult.AdditionalInfo += "Count of inactive guest users in the organization: " + $inactiveGuestUsersCount; $controlResult.SetStateData("Inactive guest 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("`nCount of users who were never active: $($neverActiveUsersCount)"); $neverActiveUsersTable = ($neverActiveUsers | FT | Out-String) $controlResult.AddMessage("Never active guest users list: `n$neverActiveUsersTable"); # show in table $controlResult.AdditionalInfo += "Count 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("`nCount of guest users who are inactive from last $($GuestUserInactivePeriodInDays) days: $($inactiveUsersWithDaysCount)"); $inactiveUsersTable = ($inactiveUsersWithDays | FT | Out-String) $controlResult.AddMessage("Inactive guest users list: `n$inactiveUsersTable"); $controlResult.AdditionalInfo += "Count of guest users who are inactive from last $($GuestUserInactivePeriodInDays) days: " + $inactiveUsersWithDaysCount; } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No guest users found to be inactive from last $($GuestUserInactivePeriodInDays) days.") } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No guest users found in org."); } } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch the list of guest users in the organization."); $controlResult.LogException($_) } return $controlResult; } hidden [void] FetchGuestMembersInOrg() { try { $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?%24filter=userType%20eq%20%27guest%27&%24orderBy=name%20Ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName) $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $guestAccounts = @() if(($null -ne $responseObj) -and $responseObj.Count -gt 0 -and ([Helpers]::CheckMember($responseObj[0], 'members'))) { $guestAccounts = @($responseObj[0].members) $continuationToken = $responseObj[0].continuationToken # Use the continuationToken for pagination while ($null -ne $continuationToken){ $urlEncodedToken = [System.Web.HttpUtility]::UrlEncode($continuationToken) $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?continuationToken=$urlEncodedToken&%24filter=userType%20eq%20%27guest%27&%24orderBy=name%20Ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName); try{ $response = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $guestAccounts += $response[0].members $continuationToken = $response[0].continuationToken } catch { # Eating the exception here as we could not fetch the further guest users $continuationToken = $null throw } } $this.GuestMembers = @($guestAccounts) } } catch { throw } } hidden [void] FetchAllUsersInOrg() { try { $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?filter=&sortOption=lastAccessDate+ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName) $responseObj = @([WebRequestHelper]::InvokeGetWebRequest($apiURL)); $AllUsersAccounts = @() if(($null -ne $responseObj) -and $responseObj.Count -gt 0 -and ([Helpers]::CheckMember($responseObj[0], 'members'))) { $AllUsersAccounts = @($responseObj[0].members) $continuationToken = $responseObj[0].continuationToken # Use the continuationToken for pagination while ($null -ne $continuationToken){ $urlEncodedToken = [System.Web.HttpUtility]::UrlEncode($continuationToken) $apiURL = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?continuationToken=$urlEncodedToken&filter=&sortOption=lastAccessDate+ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName); try{ $response = [WebRequestHelper]::InvokeGetWebRequest($apiURL); $AllUsersAccounts += $response[0].members $continuationToken = $response[0].continuationToken } catch { # Eating the exception here as we could not fetch the further guest users $continuationToken = $null throw } } $this.AllUsersInOrg = @($AllUsersAccounts) } } catch { throw } } hidden [ControlResult] CheckGuestUsersAccessInAdminRoles([ControlResult] $controlResult) { if($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings,"Organization.AdminGroupsToCheckForGuestUser")) { try { $controlResult.VerificationResult = [VerificationResult]::Failed $AdminGroupsToCheckForGuestUser = @($this.ControlSettings.Organization.AdminGroupsToCheckForGuestUser) if($this.GuestMembers.Count -eq 0) { $this.FetchGuestMembersInOrg() } $guestAccounts = @($this.GuestMembers) if($guestAccounts.Count -gt 0) { $formattedData = @() $guestAccounts | ForEach-Object { if([Helpers]::CheckMember($_,"user.descriptor")) { try { $url = "https://vssps.dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/Graph/Memberships/$($_.user.descriptor)?api-version=6.0-preview.1" $response = @([WebRequestHelper]::InvokeGetWebRequest($url)); if([Helpers]::CheckMember($response[0],"containerDescriptor")) { foreach ($obj in $response) { $url = "https://vssps.dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/graph/groups/$($obj.containerDescriptor)?api-version=6.0-preview.1"; $res = @([WebRequestHelper]::InvokeGetWebRequest($url)); $data = $res.principalName.Split("\"); $scope = $data[0] -replace '[\[\]]' $group = $data[1] if($scope -eq $this.OrganizationContext.OrganizationName -and ($group -in $AdminGroupsToCheckForGuestUser) ) { $formattedData += @{ Group = $data[1]; Scope = $data[0]; Name = $_.user.displayName; PrincipalName = $_.user.principalName; } } } } } catch { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch the membership details for the user") } } else { $controlResult.AddMessage([VerificationResult]::Error,"Could not fetch descriptor for guest user"); } } if($formattedData.Count -gt 0) { $formattedData = $formattedData | select-object @{Name="Display Name"; Expression={$_.Name}}, @{Name="User or scope"; Expression={$_.Scope}} , @{Name="Group"; Expression={$_.Group}}, @{Name="Principal Name"; Expression={$_.PrincipalName}} $groups = $formattedData | Group-Object "Principal Name" $results = @() $results += foreach( $grpobj in $groups ){ $PrincipalName = $grpobj.name $OrgGroup = $grpobj.group.group -join ',' $DisplayName = $grpobj.group."Display Name" | select -Unique $Scope = $grpobj.group."User or scope" | select -Unique [PSCustomObject]@{ PrincipalName = $PrincipalName ; DisplayName = $DisplayName ; Group = $OrgGroup ; Scope = $Scope } } $controlResult.AddMessage([VerificationResult]::Failed,"Count of guest users in admin roles: $($results.count) "); $controlResult.AddMessage("`nGuest account details:") $display = ($results|FT -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of guest users: ", $results); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No guest users have admin roles in the organization."); } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No Guest User found."); } $controlResult.AddMessage("`nNote:`nThe following groups are considered for administrator privileges: `n$($AdminGroupsToCheckForGuestUser | FT | out-string)`n"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Could not fetch user entitlements."); $controlResult.LogException($_) } } else{ $controlResult.AddMessage([VerificationResult]::Error, "List of admin groups for detecting non guest accounts is not defined in control setting of your organization."); } return $controlResult } hidden [ControlResult] CheckInactiveUsersInAdminRoles([ControlResult] $controlResult) { if($this.ControlSettings -and [Helpers]::CheckMember($this.ControlSettings,"Organization.AdminGroupsToCheckForInactiveUser")) { try { $controlResult.VerificationResult = [VerificationResult]::Failed $AdminGroupsToCheckForInactiveUser = @($this.ControlSettings.Organization.AdminGroupsToCheckForInactiveUser) $inactiveUsersWithAdminAccess = @() $inactivityThresholdInDays = 90 if([Helpers]::CheckMember($this.ControlSettings,"Organization.AdminInactivityThresholdInDays")) { $inactivityThresholdInDays = $this.ControlSettings.Organization.AdminInactivityThresholdInDays } $thresholdDate = (Get-Date).AddDays(-$inactivityThresholdInDays) ## API Call to fetch Org level collection groups $url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_apis/Contribution/HierarchyQuery?api-version=5.0-preview.1" $body = '{"contributionIds": ["ms.vss-admin-web.org-admin-groups-data-provider"],"dataProviderContext": {"properties": {"sourcePage":{"url":"","routeId":"ms.vss-admin-web.collection-admin-hub-route","routeValues":{"adminPivot":"groups","controller":"ContributedPage","action":"Execute"}}}}}'| ConvertFrom-Json $body.dataProviderContext.properties.sourcePage.url = "https://dev.azure.com/$($this.OrganizationContext.OrganizationName)/_settings/groups" $response = @([WebRequestHelper]::InvokePostWebRequest($url, $body)) if([Helpers]::CheckMember($response[0],"dataProviders") -and $response[0].dataProviders."ms.vss-admin-web.org-admin-groups-data-provider") { $OrgCollectionGroups = @($response[0].dataProviders.'ms.vss-admin-web.org-admin-groups-data-provider'.identities) $ReqdAdminGroups = @($OrgCollectionGroups | Where-Object { $_.displayName -in $AdminGroupsToCheckForInactiveUser }) $allAdminMembers =@(); $ReqdAdminGroups | ForEach-Object{ $currentGroup = $_ # [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($currentGroup.descriptor, $this.OrganizationContext.OrganizationName) $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. if($groupMembers.count -gt 0) { $groupMembers | ForEach-Object {$allAdminMembers += @( [PSCustomObject] @{ name = $_.displayName; mailAddress = $_.mailAddress; groupName = $currentGroup.displayName ; descriptor = $_.descriptor } )} } } $AdminUsersMasterList = @() $AdminUsersFailureCases = @() $controlResult.AddMessage("Found total $($allAdminMembers.count) admin users in the org.") if($allAdminMembers.count -gt 0) { $groups = $allAdminMembers | Group-Object "mailAddress" $AdminUsersMasterList += foreach( $grpobj in $groups ){ $PrincipalName = $grpobj.name $OrgGroup = ($grpobj.group.groupName | select -Unique)-join ',' $DisplayName = $grpobj.group.name | select -Unique $date = "" $descriptor = $grpobj.group.descriptor | select -Unique [PSCustomObject]@{ PrincipalName = $PrincipalName ; DisplayName = $DisplayName ; Group = $OrgGroup ; LastAccessedDate = $date ; Descriptor = $descriptor} } $inactiveUsersWithAdminAccess =@() if($AdminUsersMasterList.count -gt 0) { $currentObj = $null $AdminUsersMasterList | ForEach-Object{ try { if([Helpers]::CheckMember($_,"PrincipalName")) { $currentObj = $_ $url = "https://vsaex.dev.azure.com/{0}/_apis/UserEntitlements?%24filter=name%20eq%20%27{1}%27&%24orderBy=name%20Ascending&api-version=6.1-preview.3" -f $($this.OrganizationContext.OrganizationName), $_.PrincipalName; $response = @([WebRequestHelper]::InvokeGetWebRequest($url)); if([Helpers]::CheckMember($response[0],"members.lastAccessedDate")) { $members = @($response[0].members) if($members.count -gt 1) { $members = $members | where-object {$_.user.descriptor -eq $currentObj.Descriptor } } $dateobj = [datetime]::Parse($members[0].lastAccessedDate) if($dateobj -lt $thresholdDate ) { $formatLastRunTimeSpan = New-TimeSpan -Start $dateobj if(($formatLastRunTimeSpan).Days -gt 10000) { $_.LastAccessedDate = "User was never active" } else { $_.LastAccessedDate = $dateobj.ToString("MM-dd-yyyy") } $inactiveUsersWithAdminAccess += $_ } } } } catch { $controlResult.LogException($_) $AdminUsersFailureCases += $currentObj } } } } else { $controlResult.AddMessage([VerificationResult]::Passed, "No user found with admin roles in the organization.") } if($null -eq (Compare-Object -ReferenceObject $AdminUsersMasterList -DifferenceObject $AdminUsersFailureCases)) { $controlResult.AddMessage([VerificationResult]::Error, "Unable to fetch details of inactive users in admin role. Please run the scan with admin priveleges.") } elseif($inactiveUsersWithAdminAccess.count -gt 0) { $controlResult.AddMessage([VerificationResult]::Failed,"Count of users found inactive for $($inactivityThresholdInDays) days in admin roles: $($inactiveUsersWithAdminAccess.count) "); $controlResult.AddMessage("`nInactive user details:") $display = ($inactiveUsersWithAdminAccess|FT PrincipalName,DisplayName,Group,LastAccessedDate -AutoSize | Out-String -Width 512) $controlResult.AddMessage($display) $controlResult.SetStateData("List of inactive users: ", $inactiveUsersWithAdminAccess); } else { $controlResult.AddMessage([VerificationResult]::Passed, "No users in org admin roles have been inactive for $($inactivityThresholdInDays) days."); } } else { $controlResult.AddMessage([VerificationResult]::Error, "Not able to fetch Org level collection groups") } $controlResult.AddMessage("`nNote:`nThe following groups are considered for administrator privileges: `n$($AdminGroupsToCheckForInactiveUser|FT|Out-String)"); } catch { $controlResult.AddMessage([VerificationResult]::Error, "Not able to fetch Org level collection groups") $controlResult.LogException($_) } } else{ $controlResult.AddMessage([VerificationResult]::Error, "List of admin groups for detecting inactive accounts is not defined in control setting of your organization."); } return $controlResult; } } # SIG # Begin signature block # MIIjlAYJKoZIhvcNAQcCoIIjhTCCI4ECAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCO8opgyMUjKZHH # o8jczPqO1NL1r+4osMyYs1hDABjqjaCCDYEwggX/MIID56ADAgECAhMzAAAB32vw # LpKnSrTQAAAAAAHfMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjAxMjE1MjEzMTQ1WhcNMjExMjAyMjEzMTQ1WjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQC2uxlZEACjqfHkuFyoCwfL25ofI9DZWKt4wEj3JBQ48GPt1UsDv834CcoUUPMn # s/6CtPoaQ4Thy/kbOOg/zJAnrJeiMQqRe2Lsdb/NSI2gXXX9lad1/yPUDOXo4GNw # PjXq1JZi+HZV91bUr6ZjzePj1g+bepsqd/HC1XScj0fT3aAxLRykJSzExEBmU9eS # yuOwUuq+CriudQtWGMdJU650v/KmzfM46Y6lo/MCnnpvz3zEL7PMdUdwqj/nYhGG # 3UVILxX7tAdMbz7LN+6WOIpT1A41rwaoOVnv+8Ua94HwhjZmu1S73yeV7RZZNxoh # EegJi9YYssXa7UZUUkCCA+KnAgMBAAGjggF+MIIBejAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUOPbML8IdkNGtCfMmVPtvI6VZ8+Mw # UAYDVR0RBEkwR6RFMEMxKTAnBgNVBAsTIE1pY3Jvc29mdCBPcGVyYXRpb25zIFB1 # ZXJ0byBSaWNvMRYwFAYDVQQFEw0yMzAwMTIrNDYzMDA5MB8GA1UdIwQYMBaAFEhu # ZOVQBdOCqhc3NyK1bajKdQKVMFQGA1UdHwRNMEswSaBHoEWGQ2h0dHA6Ly93d3cu # bWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY0NvZFNpZ1BDQTIwMTFfMjAxMS0w # Ny0wOC5jcmwwYQYIKwYBBQUHAQEEVTBTMFEGCCsGAQUFBzAChkVodHRwOi8vd3d3 # Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY0NvZFNpZ1BDQTIwMTFfMjAx # MS0wNy0wOC5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEAnnqH # tDyYUFaVAkvAK0eqq6nhoL95SZQu3RnpZ7tdQ89QR3++7A+4hrr7V4xxmkB5BObS # 0YK+MALE02atjwWgPdpYQ68WdLGroJZHkbZdgERG+7tETFl3aKF4KpoSaGOskZXp # TPnCaMo2PXoAMVMGpsQEQswimZq3IQ3nRQfBlJ0PoMMcN/+Pks8ZTL1BoPYsJpok # t6cql59q6CypZYIwgyJ892HpttybHKg1ZtQLUlSXccRMlugPgEcNZJagPEgPYni4 # b11snjRAgf0dyQ0zI9aLXqTxWUU5pCIFiPT0b2wsxzRqCtyGqpkGM8P9GazO8eao # mVItCYBcJSByBx/pS0cSYwBBHAZxJODUqxSXoSGDvmTfqUJXntnWkL4okok1FiCD # Z4jpyXOQunb6egIXvkgQ7jb2uO26Ow0m8RwleDvhOMrnHsupiOPbozKroSa6paFt # VSh89abUSooR8QdZciemmoFhcWkEwFg4spzvYNP4nIs193261WyTaRMZoceGun7G # CT2Rl653uUj+F+g94c63AhzSq4khdL4HlFIP2ePv29smfUnHtGq6yYFDLnT0q/Y+ # Di3jwloF8EWkkHRtSuXlFUbTmwr/lDDgbpZiKhLS7CBTDj32I0L5i532+uHczw82 # oZDmYmYmIUSMbZOgS65h797rj5JJ6OkeEUJoAVwwggd6MIIFYqADAgECAgphDpDS # AAAAAAADMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMK # V2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0 # IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0 # ZSBBdXRob3JpdHkgMjAxMTAeFw0xMTA3MDgyMDU5MDlaFw0yNjA3MDgyMTA5MDla # MH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS # ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMT # H01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTEwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQCr8PpyEBwurdhuqoIQTTS68rZYIZ9CGypr6VpQqrgG # OBoESbp/wwwe3TdrxhLYC/A4wpkGsMg51QEUMULTiQ15ZId+lGAkbK+eSZzpaF7S # 35tTsgosw6/ZqSuuegmv15ZZymAaBelmdugyUiYSL+erCFDPs0S3XdjELgN1q2jz # y23zOlyhFvRGuuA4ZKxuZDV4pqBjDy3TQJP4494HDdVceaVJKecNvqATd76UPe/7 # 4ytaEB9NViiienLgEjq3SV7Y7e1DkYPZe7J7hhvZPrGMXeiJT4Qa8qEvWeSQOy2u # M1jFtz7+MtOzAz2xsq+SOH7SnYAs9U5WkSE1JcM5bmR/U7qcD60ZI4TL9LoDho33 # X/DQUr+MlIe8wCF0JV8YKLbMJyg4JZg5SjbPfLGSrhwjp6lm7GEfauEoSZ1fiOIl # XdMhSz5SxLVXPyQD8NF6Wy/VI+NwXQ9RRnez+ADhvKwCgl/bwBWzvRvUVUvnOaEP # 6SNJvBi4RHxF5MHDcnrgcuck379GmcXvwhxX24ON7E1JMKerjt/sW5+v/N2wZuLB # l4F77dbtS+dJKacTKKanfWeA5opieF+yL4TXV5xcv3coKPHtbcMojyyPQDdPweGF # RInECUzF1KVDL3SV9274eCBYLBNdYJWaPk8zhNqwiBfenk70lrC8RqBsmNLg1oiM # CwIDAQABo4IB7TCCAekwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFEhuZOVQ # BdOCqhc3NyK1bajKdQKVMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1Ud # DwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFHItOgIxkEO5FAVO # 4eqnxzHRI4k0MFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwubWljcm9zb2Z0 # LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcmwwXgYIKwYBBQUHAQEEUjBQME4GCCsGAQUFBzAChkJodHRwOi8vd3d3Lm1p # Y3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y # Mi5jcnQwgZ8GA1UdIASBlzCBlDCBkQYJKwYBBAGCNy4DMIGDMD8GCCsGAQUFBwIB # FjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2RvY3MvcHJpbWFyeWNw # cy5odG0wQAYIKwYBBQUHAgIwNB4yIB0ATABlAGcAYQBsAF8AcABvAGwAaQBjAHkA # XwBzAHQAYQB0AGUAbQBlAG4AdAAuIB0wDQYJKoZIhvcNAQELBQADggIBAGfyhqWY # 4FR5Gi7T2HRnIpsLlhHhY5KZQpZ90nkMkMFlXy4sPvjDctFtg/6+P+gKyju/R6mj # 82nbY78iNaWXXWWEkH2LRlBV2AySfNIaSxzzPEKLUtCw/WvjPgcuKZvmPRul1LUd # d5Q54ulkyUQ9eHoj8xN9ppB0g430yyYCRirCihC7pKkFDJvtaPpoLpWgKj8qa1hJ # Yx8JaW5amJbkg/TAj/NGK978O9C9Ne9uJa7lryft0N3zDq+ZKJeYTQ49C/IIidYf # wzIY4vDFLc5bnrRJOQrGCsLGra7lstnbFYhRRVg4MnEnGn+x9Cf43iw6IGmYslmJ # aG5vp7d0w0AFBqYBKig+gj8TTWYLwLNN9eGPfxxvFX1Fp3blQCplo8NdUmKGwx1j # NpeG39rz+PIWoZon4c2ll9DuXWNB41sHnIc+BncG0QaxdR8UvmFhtfDcxhsEvt9B # xw4o7t5lL+yX9qFcltgA1qFGvVnzl6UJS0gQmYAf0AApxbGbpT9Fdx41xtKiop96 # eiL6SJUfq/tHI4D1nvi/a7dLl+LrdXga7Oo3mXkYS//WsyNodeav+vyL6wuA6mk7 # r/ww7QRMjt/fdW1jkT3RnVZOT7+AVyKheBEyIXrvQQqxP/uozKRdwaGIm1dxVk5I # RcBCyZt2WwqASGv9eZ/BvW1taslScxMNelDNMYIVaTCCFWUCAQEwgZUwfjELMAkG # A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx # HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9z # b2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAxMQITMwAAAd9r8C6Sp0q00AAAAAAB3zAN # BglghkgBZQMEAgEFAKCBsDAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgor # BgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQghUrA0bkN # KiI+UUt7X+maIjRvOyvrNIdez4JMwm2tBr0wRAYKKwYBBAGCNwIBDDE2MDSgFIAS # AE0AaQBjAHIAbwBzAG8AZgB0oRyAGmh0dHBzOi8vd3d3Lm1pY3Jvc29mdC5jb20g # MA0GCSqGSIb3DQEBAQUABIIBADyyNIHm4xO6kZVjW7Kj8vPWkcs3KPuDeQUiaDkX # hzp/xErxNoSBfIgz3ugXgpwubqDDW0H+tEodvwAcX4iiAkO3/4kL7soO55VPs+hl # jF3YDXFJZBCPDE+jkjuHORdzvSBMhf6NOT153uEnvA1X7d0Y5Xvh/kUN8lFDapZW # AWWruBv/VnX/kYfhvA0eEJMpMzKJ/rd8EvrjrVxERME2H5Ut54Xxvc3LzUHt6VS0 # 4rf5ychDHaWza2pwLhlv5xzNxxtgvhV2C+1W1OSAFzZX4N4PeOmITj1yayYDszuz # /sDe4AcQv9cD6gYvmenmfSCRLvAjKoWuVMt6zu6DqefSyCWhghLxMIIS7QYKKwYB # BAGCNwMDATGCEt0wghLZBgkqhkiG9w0BBwKgghLKMIISxgIBAzEPMA0GCWCGSAFl # AwQCAQUAMIIBVQYLKoZIhvcNAQkQAQSgggFEBIIBQDCCATwCAQEGCisGAQQBhFkK # AwEwMTANBglghkgBZQMEAgEFAAQgFJwdO9UE2cMC9eUcnKYDy6yYPsNMKsuy2kV4 # Ly6+0bYCBmCvtVzjcBgTMjAyMTA2MTUwNjMzMDguOTY4WjAEgAIB9KCB1KSB0TCB # zjELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl # ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEpMCcGA1UECxMg # TWljcm9zb2Z0IE9wZXJhdGlvbnMgUHVlcnRvIFJpY28xJjAkBgNVBAsTHVRoYWxl # cyBUU1MgRVNOOkM0QkQtRTM3Ri01RkZDMSUwIwYDVQQDExxNaWNyb3NvZnQgVGlt # ZS1TdGFtcCBTZXJ2aWNloIIORDCCBPUwggPdoAMCAQICEzMAAAFXRAdi3G/ovioA # AAAAAVcwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldh # c2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBD # b3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIw # MTAwHhcNMjEwMTE0MTkwMjEzWhcNMjIwNDExMTkwMjEzWjCBzjELMAkGA1UEBhMC # VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV # BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEpMCcGA1UECxMgTWljcm9zb2Z0IE9w # ZXJhdGlvbnMgUHVlcnRvIFJpY28xJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNOOkM0 # QkQtRTM3Ri01RkZDMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2 # aWNlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3m0Dp1Rm+efAv2pC # 1dzA8A2EHh7P7kJCt4+n9nxMfg0Gvm8B8YyjSVX+WJ0Fq0pOAcSs64ofXXFUB8F6 # Ecm8f1P86E5zzcImz1vMOGuV3Ql3Ld4nILTIF3FV65xL7ZrZkF3nTAGD/n/ZiNDb # KV8PR3Eorq1AvF04NO5p1Axt1rTmU8adYbBneeJKAgpVGCqoJWWEfPA21GHUAf5n # Ft9J7u3zPegQoB1MDLtKw/zKSG3eyuN2HQHKQ8V2loCCrBYIkkmYaTSACtK8cLz6 # 9e0ajcwmFZBF7km3N0PmR1oof25z2CdKGxfIMSEZmPHf5vxy6oQ7xse/RY9f0xER # +t/G+QIDAQABo4IBGzCCARcwHQYDVR0OBBYEFF0xe7voOCGdT+Q9Mwp0WRH2gKnZ # MB8GA1UdIwQYMBaAFNVjOlyKMZDzQ3t8RhvFM2hahW1VMFYGA1UdHwRPME0wS6BJ # oEeGRWh0dHA6Ly9jcmwubWljcm9zb2Z0LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01p # Y1RpbVN0YVBDQV8yMDEwLTA3LTAxLmNybDBaBggrBgEFBQcBAQROMEwwSgYIKwYB # BQUHMAKGPmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljVGlt # U3RhUENBXzIwMTAtMDctMDEuY3J0MAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI # KwYBBQUHAwgwDQYJKoZIhvcNAQELBQADggEBACV3eQCAbpdaJnK92JstGZavvJvp # FLJyNUODy1wKK1LTWxNWnhPwB3ZB5h8lZ8roMwSTtBEF8qB03ugTx1e2ZBUv4lzE # uPSlS7Lg0HlFyFy14Pl1GdN8qVGLy+ApRrENygUjM0RTPUQemil5qANvj+4j1SPm # 0i7CWKT+qu/+wcDDuQziAQss06B16/1n/vGjUkjB97R6hAzfDFwIUu5/xL06dy21 # oUBYe0QRHwi+BECAsn9aeW4XPrz6GsN9HJf+qpZI8gTS+gTqoXHXPxS8vAqmbrlA # 3I0NEyn9WYKmpFmvEHWjRFjs/6fiNI0a9uTZtHvSQq392iAUVEEdVW5TF/4wggZx # MIIEWaADAgECAgphCYEqAAAAAAACMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQG # EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG # A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQg # Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxMDAeFw0xMDA3MDEyMTM2NTVa # Fw0yNTA3MDEyMTQ2NTVaMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5n # dG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9y # YXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMIIB # IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqR0NvHcRijog7PwTl/X6f2mU # a3RUENWlCgCChfvtfGhLLF/Fw+Vhwna3PmYrW/AVUycEMR9BGxqVHc4JE458YTBZ # sTBED/FgiIRUQwzXTbg4CLNC3ZOs1nMwVyaCo0UN0Or1R4HNvyRgMlhgRvJYR4Yy # hB50YWeRX4FUsc+TTJLBxKZd0WETbijGGvmGgLvfYfxGwScdJGcSchohiq9LZIlQ # YrFd/XcfPfBXday9ikJNQFHRD5wGPmd/9WbAA5ZEfu/QS/1u5ZrKsajyeioKMfDa # TgaRtogINeh4HLDpmc085y9Euqf03GS9pAHBIAmTeM38vMDJRF1eFpwBBU8iTQID # AQABo4IB5jCCAeIwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFNVjOlyKMZDz # Q3t8RhvFM2hahW1VMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1UdDwQE # AwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNX2VsuP6KJcYmjRPZSQ # W9fOmhjEMFYGA1UdHwRPME0wS6BJoEeGRWh0dHA6Ly9jcmwubWljcm9zb2Z0LmNv # bS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNybDBa # BggrBgEFBQcBAQROMEwwSgYIKwYBBQUHMAKGPmh0dHA6Ly93d3cubWljcm9zb2Z0 # LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3J0MIGgBgNV # HSABAf8EgZUwgZIwgY8GCSsGAQQBgjcuAzCBgTA9BggrBgEFBQcCARYxaHR0cDov # L3d3dy5taWNyb3NvZnQuY29tL1BLSS9kb2NzL0NQUy9kZWZhdWx0Lmh0bTBABggr # BgEFBQcCAjA0HjIgHQBMAGUAZwBhAGwAXwBQAG8AbABpAGMAeQBfAFMAdABhAHQA # ZQBtAGUAbgB0AC4gHTANBgkqhkiG9w0BAQsFAAOCAgEAB+aIUQ3ixuCYP4FxAz2d # o6Ehb7Prpsz1Mb7PBeKp/vpXbRkws8LFZslq3/Xn8Hi9x6ieJeP5vO1rVFcIK1GC # RBL7uVOMzPRgEop2zEBAQZvcXBf/XPleFzWYJFZLdO9CEMivv3/Gf/I3fVo/HPKZ # eUqRUgCvOA8X9S95gWXZqbVr5MfO9sp6AG9LMEQkIjzP7QOllo9ZKby2/QThcJ8y # Sif9Va8v/rbljjO7Yl+a21dA6fHOmWaQjP9qYn/dxUoLkSbiOewZSnFjnXshbcOc # o6I8+n99lmqQeKZt0uGc+R38ONiU9MalCpaGpL2eGq4EQoO4tYCbIjggtSXlZOz3 # 9L9+Y1klD3ouOVd2onGqBooPiRa6YacRy5rYDkeagMXQzafQ732D8OE7cQnfXXSY # Ighh2rBQHm+98eEA3+cxB6STOvdlR3jo+KhIq/fecn5ha293qYHLpwmsObvsxsvY # grRyzR30uIUBHoD7G4kqVDmyW9rIDVWZeodzOwjmmC3qjeAzLhIp9cAvVCch98is # TtoouLGp25ayp0Kiyc8ZQU3ghvkqmqMRZjDTu3QyS99je/WZii8bxyGvWbWu3EQ8 # l1Bx16HSxVXjad5XwdHeMMD9zOZN+w2/XU/pnR4ZOC+8z1gFLu8NoFA12u8JJxzV # s341Hgi62jbb01+P3nSISRKhggLSMIICOwIBATCB/KGB1KSB0TCBzjELMAkGA1UE # BhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAc # BgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEpMCcGA1UECxMgTWljcm9zb2Z0 # IE9wZXJhdGlvbnMgUHVlcnRvIFJpY28xJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNO # OkM0QkQtRTM3Ri01RkZDMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBT # ZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQARLfhJYnsN9tIb+BshDBOvOBnw8qCBgzCB # gKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQH # EwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNV # BAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3DQEBBQUA # AgUA5HKXIjAiGA8yMDIxMDYxNTA3MDQwMloYDzIwMjEwNjE2MDcwNDAyWjB3MD0G # CisGAQQBhFkKBAExLzAtMAoCBQDkcpciAgEAMAoCAQACAiHkAgH/MAcCAQACAhH7 # MAoCBQDkc+iiAgEAMDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKgCjAI # AgEAAgMHoSChCjAIAgEAAgMBhqAwDQYJKoZIhvcNAQEFBQADgYEAQ2BgcT0bR7Aw # u3ExeRa/7mB2qiHWhVGEbD8cU/AxMGt3nkiNPIrGglWrwzUbc35RsjnPA8xm/CQv # OV/Bq1gbonhE5GtCTD3AXL2Jm1zkOIiTlUd8JwvxCDVbZeqvqp+/bbNyhRb5qkVg # xwJfyVgbkMfgjEvsnfdZbyIuArpiIDwxggMNMIIDCQIBATCBkzB8MQswCQYDVQQG # EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG # A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQg # VGltZS1TdGFtcCBQQ0EgMjAxMAITMwAAAVdEB2Lcb+i+KgAAAAABVzANBglghkgB # ZQMEAgEFAKCCAUowGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8GCSqGSIb3 # DQEJBDEiBCCoQmGmlYS5SapqjE05v/W/W5GD0L+yXTT01cf9dJMBFTCB+gYLKoZI # hvcNAQkQAi8xgeowgecwgeQwgb0EICxajQ1Dq/O666lSxkQxInhSxGO1DDZ0XFla # Qe2pHKATMIGYMIGApH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0 # b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3Jh # dGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMA # AAFXRAdi3G/ovioAAAAAAVcwIgQgvcl2qFEBLacV8nR7CRdTYoyvVsHezdVpP7U9 # +UmEXiEwDQYJKoZIhvcNAQELBQAEggEA1OqS4rY3dfkWDDnBj21KQhZKyt+fgb0k # SKYs7yD8gWewXGdXSmDPOve6+N6Gw5MtY35GQf3+GRLxhWv1/6Vg5Rw52bmS4JxQ # l0ftWUhX0XdZqFa+rF8gwoWP16OrucZAxaKISPbjaMz/d41A3SFohdeQNGrT+YDq # jA4sJVSFsYgkdKJTHYsBpAdL40MkY6Qo3ky7K3I1hc49si7vuK+T9qF9vu+BRQEk # Vgi9Llil5JzOKmhLg+uzNLD2SKpCMIkYgScHoc7cBbvj5CVFT9x4z26auOK9ShXj # dw7Uo6ea/XNjO+G+yHHyk83sRav2cd9hs86DUYypvFkFTMAbQSAt7Q== # SIG # End signature block |