├── .gitignore ├── .gitattributes ├── SOA ├── resources.json ├── SOA.psd1 ├── SOA-ImportExport.psm1 └── SOA-Prerequisites.psm1 ├── docs ├── soaversion.json ├── moduleversion.json ├── dirsyncversion.json └── index.html ├── .github ├── release.yml ├── build │ └── build.ps1 └── workflows │ └── psgallery.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Settings within the .vscode directory 2 | .vscode/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.psd1 diff -------------------------------------------------------------------------------- /SOA/resources.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o365soa/soa/HEAD/SOA/resources.json -------------------------------------------------------------------------------- /docs/soaversion.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "CurrentVersion": "7.19", 4 | "WarningStartDate": "\/Date(1764982866566)\/", 5 | "ExecutionEndDate": "\/Date(1765242087323)\/" 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: Breaking Changes 7 | labels: 8 | - Semver-Major 9 | - breaking-change 10 | - title: New Features 11 | labels: 12 | - Semver-Minor 13 | - enhancement 14 | - title: Other Changes 15 | labels: 16 | - "*" -------------------------------------------------------------------------------- /.github/build/build.ps1: -------------------------------------------------------------------------------- 1 | $Global:ErrorActionPreference = 'Stop' 2 | $Global:VerbosePreference = 'SilentlyContinue' 3 | 4 | ### Prepare NuGet / PSGallery 5 | if (!(Get-PackageProvider | Where-Object { $_.Name -eq 'NuGet' })) { 6 | Write-Verbose "Installing NuGet" -Verbose 7 | Install-PackageProvider -Name NuGet -force | Out-Null 8 | } 9 | Write-Verbose "Preparing PSGallery repository" -Verbose 10 | Import-PackageProvider -Name NuGet -force | Out-Null 11 | if ((Get-PSRepository -Name PSGallery).InstallationPolicy -ne 'Trusted') { 12 | Set-PSRepository -Name PSGallery -InstallationPolicy Trusted 13 | } -------------------------------------------------------------------------------- /docs/moduleversion.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ModuleName": "Microsoft.Online.SharePoint.PowerShell", 4 | "MaximumVersion": "" 5 | }, 6 | { 7 | "ModuleName": "MicrosoftTeams", 8 | "MaximumVersion": "" 9 | }, 10 | { 11 | "ModuleName": "ExchangeOnlineManagement", 12 | "MaximumVersion": "3.6.0" 13 | }, 14 | { 15 | "ModuleName": "Microsoft.PowerApps.Administration.PowerShell", 16 | "MaximumVersion": "" 17 | }, 18 | { 19 | "ModuleName": "Microsoft.Graph.Authentication", 20 | "MaximumVersion": "" 21 | } 22 | ] -------------------------------------------------------------------------------- /.github/workflows/psgallery.yml: -------------------------------------------------------------------------------- 1 | name: Publish PowerShell Module 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-to-gallery: 9 | runs-on: windows-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | ref: master 14 | - name: Build and publish 15 | env: 16 | NUGET_KEY: ${{ secrets.NUGET_KEY }} 17 | MODULE_VER: ${{ github.ref_name }} 18 | shell: pwsh 19 | run: | 20 | .\.github\build\build.ps1 21 | Update-ModuleManifest -Path ".\SOA\SOA.psd1" -ModuleVersion ($env:MODULE_VER).TrimStart("v") 22 | Publish-Module -Path ".\SOA" -NuGetApiKey $env:NUGET_KEY -Verbose 23 | - name: Push commit 24 | run: | 25 | git config --global user.name 'github-actions' 26 | git config --global user.email 'github-actions@github.com' 27 | git add --all . 28 | git commit -am "Bump version" 29 | git push -------------------------------------------------------------------------------- /docs/dirsyncversion.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "Version": "2.5.190.0", 5 | "SupportEndDate": "\/Date(1914476400000)\/", 6 | "SecurityUpdate": false 7 | }, 8 | { 9 | "Version": "2.5.79.0", 10 | "SupportEndDate": "\/Date(1792738800000)\/", 11 | "SecurityUpdate": false 12 | }, 13 | { 14 | "Version": "2.5.76.0", 15 | "SupportEndDate": "\/Date(1788246000000)\/", 16 | "SecurityUpdate": true 17 | }, 18 | { 19 | "Version": "2.5.3.0", 20 | "SupportEndDate": "\/Date(1785481200000)\/", 21 | "SecurityUpdate": false 22 | }, 23 | { 24 | "Version": "2.4.131.0", 25 | "SupportEndDate": "\/Date(1779778800000)\/", 26 | "SecurityUpdate": false 27 | }, 28 | { 29 | "Version": "2.4.129.0", 30 | "SupportEndDate": "\/Date(1774508400000)\/", 31 | "SecurityUpdate": true 32 | }, 33 | { 34 | "Version": "2.4.27.0", 35 | "SupportEndDate": "\/Date(1768460400298)\/", 36 | "SecurityUpdate": false 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SOA Consent 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Admin consent complete

19 |

20 | Admin consent has been successfully granted to the SOA enterprise application. You can close this window. 21 |

22 | 23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /SOA/SOA.psd1: -------------------------------------------------------------------------------- 1 | # 2 | # Module manifest for module 'SOA' 3 | # 4 | # Generated by: Microsoft 5 | # 6 | # Generated on: 12/10/2025 7 | # 8 | 9 | @{ 10 | 11 | # Script module or binary module file associated with this manifest. 12 | # RootModule = '' 13 | 14 | # Version number of this module. 15 | ModuleVersion = '3.7.1' 16 | 17 | # Supported PSEditions 18 | # CompatiblePSEditions = @() 19 | 20 | # ID used to uniquely identify this module 21 | GUID = '2180f862-26aa-449a-bdbd-9217a448b159' 22 | 23 | # Author of this module 24 | Author = 'Microsoft' 25 | 26 | # Company or vendor of this module 27 | CompanyName = 'Microsoft' 28 | 29 | # Copyright statement for this module 30 | Copyright = '(c) 2024 Microsoft. All rights reserved.' 31 | 32 | # Description of the functionality provided by this module 33 | Description = 'The preqrequisites installation scripts for Microsoft security assessments' 34 | 35 | # Minimum version of the PowerShell engine required by this module 36 | PowerShellVersion = '5.1' 37 | 38 | # Name of the PowerShell host required by this module 39 | # PowerShellHostName = '' 40 | 41 | # Minimum version of the PowerShell host required by this module 42 | # PowerShellHostVersion = '' 43 | 44 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 45 | # DotNetFrameworkVersion = '' 46 | 47 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 48 | # ClrVersion = '' 49 | 50 | # Processor architecture (None, X86, Amd64) required by this module 51 | # ProcessorArchitecture = '' 52 | 53 | # Modules that must be imported into the global environment prior to importing this module 54 | # RequiredModules = @() 55 | 56 | # Assemblies that must be loaded prior to importing this module 57 | # RequiredAssemblies = @() 58 | 59 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 60 | # ScriptsToProcess = @() 61 | 62 | # Type files (.ps1xml) to be loaded when importing this module 63 | # TypesToProcess = @() 64 | 65 | # Format files (.ps1xml) to be loaded when importing this module 66 | # FormatsToProcess = @() 67 | 68 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 69 | NestedModules = @('SOA-Prerequisites.psm1', 70 | 'SOA-ImportExport.psm1') 71 | 72 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 73 | FunctionsToExport = 'Install-SOAPrerequisites', 'Test-SOAApplication', 74 | 'Invoke-SOAVersionCheck', 'Export-SOARPS', 'Get-LicenseStatus', 75 | 'Get-CloudEnvironment' 76 | 77 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 78 | CmdletsToExport = @() 79 | 80 | # Variables to export from this module 81 | VariablesToExport = '*' 82 | 83 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 84 | AliasesToExport = @() 85 | 86 | # DSC resources to export from this module 87 | # DscResourcesToExport = @() 88 | 89 | # List of all modules packaged with this module 90 | # ModuleList = @() 91 | 92 | # List of all files packaged with this module 93 | FileList = 'resources.json' 94 | 95 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 96 | PrivateData = @{ 97 | 98 | PSData = @{ 99 | 100 | # Tags applied to this module. These help with module discovery in online galleries. 101 | Tags = 'Microsoft','Security','Assessment','M365' 102 | 103 | # A URL to the license for this module. 104 | # LicenseUri = '' 105 | 106 | # A URL to the main website for this project. 107 | ProjectUri = 'https://github.com/o365soa/soa/' 108 | 109 | # A URL to an icon representing this module. 110 | # IconUri = '' 111 | 112 | # ReleaseNotes of this module 113 | # ReleaseNotes = '' 114 | 115 | # Prerelease string of this module 116 | # Prerelease = '' 117 | 118 | # Flag to indicate whether the module requires explicit user acceptance for install/update/save 119 | # RequireLicenseAcceptance = $false 120 | 121 | # External dependent modules of this module 122 | # ExternalModuleDependencies = @() 123 | 124 | } # End of PSData hashtable 125 | 126 | } # End of PrivateData hashtable 127 | 128 | # HelpInfo URI of this module 129 | # HelpInfoURI = '' 130 | 131 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 132 | # DefaultCommandPrefix = '' 133 | 134 | } 135 | 136 | -------------------------------------------------------------------------------- /SOA/SOA-ImportExport.psm1: -------------------------------------------------------------------------------- 1 | #Requires -Version 5.1 2 | 3 | <# 4 | 5 | .SYNOPSIS 6 | SOA Export/Import Module 7 | 8 | .DESCRIPTION 9 | 10 | LogAnalytics only used for SOA as a Service delivery. 11 | 12 | ############################################################################ 13 | # This sample script is not supported under any Microsoft standard support program or service. 14 | # This sample script is provided AS IS without warranty of any kind. 15 | # Microsoft further disclaims all implied warranties including, without limitation, any implied 16 | # warranties of merchantability or of fitness for a particular purpose. The entire risk arising 17 | # out of the use or performance of the sample script and documentation remains with you. In no 18 | # event shall Microsoft, its authors, or anyone else involved in the creation, production, or 19 | # delivery of the scripts be liable for any damages whatsoever (including, without limitation, 20 | # damages for loss of business profits, business interruption, loss of business information, 21 | # or other pecuniary loss) arising out of the use of or inability to use the sample script or 22 | # documentation, even if Microsoft has been advised of the possibility of such damages. 23 | ############################################################################ 24 | 25 | #> 26 | 27 | Function Export-SOARPS 28 | { 29 | Param 30 | ( 31 | [Parameter(ParameterSetName='FromFile')] 32 | $FromFile, 33 | 34 | [Parameter(ParameterSetName='FromObject')] 35 | [Array]$FromObject, 36 | 37 | [Parameter(ParameterSetName='FromObject', Mandatory=$True)] 38 | $AssessmentDate, 39 | 40 | [Parameter(ParameterSetName='FromObject')] 41 | [Parameter(ParameterSetName='FromFile')] 42 | [Parameter(ParameterSetName='OutputLogAnalytics')] 43 | [Switch]$UploadLogAnalytics, 44 | 45 | [Parameter(ParameterSetName='FromObject')] 46 | [Parameter(ParameterSetName='FromFile')] 47 | [Parameter(ParameterSetName='OutputLogAnalytics', Mandatory=$True)] 48 | [String]$LogAnalyticsWorkSpace, 49 | 50 | [Parameter(ParameterSetName='FromObject')] 51 | [Parameter(ParameterSetName='FromFile')] 52 | [Parameter(ParameterSetName='OutputLogAnalytics', Mandatory=$True)] 53 | [String]$LogAnalyticsKey 54 | ) 55 | 56 | $Success = $False 57 | 58 | if(!$FromFile -and !$FromObject) 59 | { 60 | Throw "Please specify to import from file (-FromFile filename.csv) or from object (-FromObject)" 61 | } 62 | 63 | if($FromFile) 64 | { 65 | # Check file is in right format 66 | $Regex = "^Remediation Planning (\d{8})$" 67 | 68 | Try 69 | { 70 | $Item = Get-ChildItem $FromFile 71 | } 72 | catch 73 | { 74 | Throw "Failed to find/get file $FromFile" 75 | } 76 | 77 | If($Item.BaseName -match $Regex) 78 | { 79 | $AssessmentDate = $Matches[1] 80 | 81 | $data = Import-CSV $FromFile 82 | 83 | # Add assessment date to the control 84 | ForEach($x in $data) 85 | { 86 | $x | Add-Member -MemberType NoteProperty -Name "AssessmentDate" -Value $AssessmentDate 87 | } 88 | 89 | } 90 | else 91 | { 92 | Throw "File must be named correctly as 'Remediation Planning YYYYMMDD' Replacing YYYY with Year, MM with Month (Double Digit), DD with Day (Double Digit). Example 'Remediation Planning 20200728.csv'" 93 | } 94 | 95 | } 96 | 97 | if($FromObject) 98 | { 99 | if($AssessmentDate -notmatch "^(\d{8})$") 100 | { 101 | Throw "AssessmentDate must be in format YYYYMMDD. Replacing YYYY with Year, MM with Month (Double Digit), DD with Day (Double Digit). Example '20200517'" 102 | } 103 | 104 | $data = $FromObject 105 | } 106 | 107 | if($UploadLogAnalytics) 108 | { 109 | # Post data to log analytics 110 | $json = ConvertTo-Json $data 111 | 112 | $Return = Post-LogAnalyticsData -customerId $LogAnalyticsWorkSpace -sharedKey $LogAnalyticsKey -body ([System.Text.Encoding]::UTF8.GetBytes($json)) -logType "SecurityOptimizationAssessment" 113 | 114 | If($Return -eq 200) 115 | { 116 | $Success = $True 117 | } 118 | } 119 | 120 | Return New-Object -TypeName PSObject -Property @{ 121 | Completed=$Success 122 | ControlCount=$($data.Count) 123 | } 124 | } 125 | 126 | # Create the function to create the authorization signature 127 | Function Build-Signature ($customerId, $sharedKey, $date, $contentLength, $method, $contentType, $resource) 128 | { 129 | $xHeaders = "x-ms-date:" + $date 130 | $stringToHash = $method + "`n" + $contentLength + "`n" + $contentType + "`n" + $xHeaders + "`n" + $resource 131 | 132 | $bytesToHash = [Text.Encoding]::UTF8.GetBytes($stringToHash) 133 | $keyBytes = [Convert]::FromBase64String($sharedKey) 134 | 135 | $sha256 = New-Object System.Security.Cryptography.HMACSHA256 136 | $sha256.Key = $keyBytes 137 | $calculatedHash = $sha256.ComputeHash($bytesToHash) 138 | $encodedHash = [Convert]::ToBase64String($calculatedHash) 139 | $authorization = 'SharedKey {0}:{1}' -f $customerId,$encodedHash 140 | return $authorization 141 | } 142 | 143 | 144 | # Create the function to create and post the request 145 | Function Post-LogAnalyticsData($customerId, $sharedKey, $body, $logType) 146 | { 147 | $method = "POST" 148 | $contentType = "application/json" 149 | $resource = "/api/logs" 150 | $rfc1123date = [DateTime]::UtcNow.ToString("r") 151 | $contentLength = $body.Length 152 | $signature = Build-Signature ` 153 | -customerId $customerId ` 154 | -sharedKey $sharedKey ` 155 | -date $rfc1123date ` 156 | -contentLength $contentLength ` 157 | -method $method ` 158 | -contentType $contentType ` 159 | -resource $resource 160 | $uri = "https://" + $customerId + ".ods.opinsights.azure.com" + $resource + "?api-version=2016-04-01" 161 | 162 | $headers = @{ 163 | "Authorization" = $signature; 164 | "Log-Type" = $logType; 165 | "x-ms-date" = $rfc1123date; 166 | "time-generated-field" = "DateValue"; 167 | } 168 | 169 | $response = Invoke-WebRequest -Uri $uri -Method $method -ContentType $contentType -Headers $headers -Body $body -UseBasicParsing 170 | return $response.StatusCode 171 | 172 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microsoft 365 Security Assessments Prerequisites 2 | 3 | ## Introduction 4 | 5 | The following Microsoft 365 security assessments have several prerequisites that need to be installed and configured: 6 | - Microsoft 365 Foundations - Workload Security Assessment 7 | - Security Optimization Assessment for Microsoft Defender 8 | 9 | The prerequisites installation script is included in a PowerShell module named SOA. 10 | 11 | ## Prerequisites Breakdown 12 | 13 | The latest version of the following PowerShell modules is installed: 14 | * Exchange Online 15 | * SharePoint Online 16 | * Microsoft Teams 17 | * Power Apps admin 18 | * Microsoft.Graph.Authentication (from the Microsoft Graph PowerShell SDK) 19 | * Active Directory 20 | 21 | > [!NOTE] 22 | > For SharePoint Online, if a non-PowerShell Gallery version of the module is installed, it is removed from the PS Module Path to prevent conflicts. 23 | 24 | An application, named "Microsoft Security Assessment", is also registered (created) in your tenant. Details are provided below. 25 | 26 | ## Collection machine 27 | The prerequisites need to be installed on the system that will be used for data collection. It can be any workstation or server, physical or virtual, that can connect via PowerShell to Microsoft Graph, Exchange Online, Security & Compliance Center, SharePoint Online, Microsoft Teams, and Power Platform. It does not need to be AD- or Microsoft Entra-joined unless you have Conditional Access policies requiring it for any of these connections. 28 | 29 | If directory synchronization is being used, a collection script will be run on a machine that needs to be domain-joined and has the Active Directory PowerShell module installed (whether that is the collection machine or a different machine). 30 | 31 | ## Prerequisites Script 32 | 33 | ### Requirements 34 | 35 | In order to install the SOA module and run the prerequisites script, you must have the following on the collection machine: 36 | * PowerShell 5.1 (PowerShell 7 is not supported) 37 | * PowerShellGet version 2.2.4 or higher 38 | * The installed versions can be determined by running `Get-Module PowerShellGet -ListAvailable`. If at least PowerShellGet 2.2.4 is not installed, run the following to install the latest version:
39 | 40 | `Install-Module PowerShellGet -Force`
41 | `Remove-Module PowerShellGet` (This command removes any loaded PowerShellGet module from the current session.) 42 | * PowerShell execution policy set to RemoteSigned (or Unrestricted) 43 | * The current policy can be verified by running `Get-ExecutionPolicy`. If it is not set to RemoteSigned or Unrestricted, it can be set to RemoteSigned by running the following: 44 | 45 | `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` 46 | 47 | ### Permissions 48 | * Local admin (running PowerShell as an administrator) is not required unless the Active Directory module needs to be installed (see [below](#active-directory-module)). 49 | * The user installing the prerequisites needs the following roles: 50 | * Application Administrator (or Cloud App Administrator or Privileged Role Administrator), to create the app registration. (If this is not possible, contact the resource delivering the assessment for instructions to manually create the app registration.) 51 | * Billing Administrator, to get the licenses in the tenant. 52 | * Consent must be granted to the Graph PowerShell SDK (which will prompt for it when signing in) for the following delegated scopes for the user installing the prerequisites (granting on behalf of the entire organization is not required): 53 | * Application.ReadWrite.All (If this is not possible, contact the resource delivering the assessment for instructions to manually create the app registration.) 54 | * Organization.Read.All 55 | * To grant admin consent for the app registration's permissions (see [below](#microsoft-entra-app-registration)), an account with Global Administrator or Privileged Role Administrator role is required. (The account used to create the app registration can be different than the account used to grant consent.) 56 | * For testing the connections to Exchange Online, Security & Compliance Center, SharePoint Online, Microsoft Teams, and Power Platform, the account used to sign in does not require an admin role. 57 | 58 | ### Running the prerequisites script 59 | 60 | 1. In PowerShell, run the following to install the latest version of the SOA module from the [PowerShell Gallery](https://www.powershellgallery.com/packages/SOA/): 61 | 62 | `Install-Module SOA` 63 | 64 | 2. Run the following: 65 | 66 | `Install-SOAPrerequisites` 67 | 68 | > [!IMPORTANT] 69 | > See below for optional parameters that may be applicable 70 | 71 | ## Optional parameters 72 | ### Custom (vanity) SharePoint Online domain 73 | 74 | If you use a custom domain to connect to the SharePoint Online admin endpoint (such as a multi-tenant enhanced organization), you need to specify the domain using `-SPOAdminDomain ` or the connection test to SPO will fail. 75 | 76 | ### Requiring a proxy 77 | 78 | If traffic to Microsoft 365 routes via proxy infrastructure and the prerequisites installation fails because of this, try again with `-UseProxy`. 79 | 80 | #### Microsoft Graph PowerShell SDK app registration in 21Vianet 81 | 82 | Microsoft globally registered applications, including the Graph PowerShell SDK, do not replicate to tenants operated by 21Vianet. This means an app registration must be configured to allow the SDK to connect to Microsoft Graph when using delegated authentication: 83 | 84 | 1. In the Microsoft Entra portal, navigate to **Manage** / **App registrations** and click the **New registration** button. 85 | 1. Give the app a desired name. 86 | 1. Under **Supported account types**, leave the selection at the default value for a "Single tenant" application. 87 | 1. Under **Redirect URI**, click the drop-down for "Select a platform" and select *Public client/native (mobile & desktop)*, then enter `http://localhost`. 88 | 1. Click the **Register** button. 89 | 1. In the application's **Overview** section, copy the "Application (client) ID" value, which will need to be provided using the `-GraphClientId` parameter when running `Install-SOAPrerequisites` and when running the collection script. 90 | 91 | ### Active Directory module 92 | 93 | If directory synchronization is used and the Active Directory module is not installed and you cannot run PowerShell as a local admin, you can skip the installation of the module by using `-SkipAdModule`. A machine with the module installed will be needed on the first day of the engagement to collect information about the AD environment. The module can be installed on a machine using `-AdModuleOnly` or manually via another method. 94 | 95 | ## Microsoft Entra app registration 96 | 97 | An app registration is required to use Microsoft Graph and other APIs. Registration and configuration is performed by the prerequisites script. 98 | 99 | The permission scopes added to the app registration: 100 | |API|Scope|Type|Usage| 101 | |---|---|---|---| 102 | |Graph|Application.ReadWrite.OwnedBy|Application|Update app registrations owned by the application (aka service principal). This allows the application to remove its own client secret when the prerequisites validation and data collection are complete.| 103 | |Graph|AuditLog.Read.All|Application|Get sign-in activity for user and guest accounts.| 104 | |Graph|DeviceManagementConfiguration.Read|Application|Get Intune configuration policies, if applicable.| 105 | |Graph|Directory.Read.All|Application|Get subscriptions in the tenant and sign-in activity for user and guest accounts. (Both this scope and AuditLog.Read.All are required in order to get sign-in activity.)| 106 | |Graph|IdentityRiskEvent.Read.All|Application|Get identity risk events raised by Microsoft Entra ID Protection.| 107 | |Graph|OnPremDirectorySynchronization.Read.All|Application|Get Microsoft Entra directory synchronization settings.| 108 | Graph|Policy.Read.All|Application|Get various Microsoft Entra policies, such as authorization, cross-tenant access, and conditional access.| 109 | |Graph|PrivilegedAccess.Read.AzureADGroup|Application|Get Privileged Identity Management roles assigned to groups. 110 | |Graph|RoleManagement.Read.All|Application|Get Privileged Identity Management roles assigned to users.| 111 | |Graph|SecurityAlert.Read.All|Application|For organizations with Microsoft Defender for Office 365 (Plan 2) or Microsoft Defender for Endpoint, get Defender alerts.| 112 | |Graph|SecurityIdentitiesHealth.Read.All|Application|For organizations with Microsoft Defender for Identity, get health alerts.| 113 | |Graph|SecurityEvents.Read.All|Application|For organizations with Microsoft Defender for Identity, get configuration details from Secure Score that do not have an API available.| 114 | |Graph|SecurityIdentitiesSensors.Read.All|Application|For organizations with Microsoft Defender for Identity, get sensor details.| 115 | |Graph|ThreatHunting.Read.All|Application|For organizations with Microsoft Defender for Office 365 P2, get active automated investigations. For organizations with Microsoft Defender for Endpoint, get health alerts.| 116 | |Dynamics CRM|user_impersonation|Delegated|Get Dataverse settings.| 117 | 118 | ### App registration security 119 | 120 | As a security-related assessment, the security of the app registration is paramount, which is why the following considerations are made: 121 | * The app registration is scoped to specific activities, as indicated above, using a least-privilege model. All scopes are read-only (except for OwnedBy so it can remove its client secret) and grant access only to configuration settings, not any user-generated data. 122 | * Client secrets 123 | * A client secret (a password randomly generated by Microsoft Entra) is created by the installation script for validating the configuration of the app registration. It is set to expire after 48 hours, but is removed from the app registration when the validation is complete. 124 | * When the collection script is run, a client secret (also set to expire after 48 hours) is created to be able to retrieve the necessary data, but is removed from the app registration when the collection script is complete. 125 | * The client secret is stored only in memory by the script and is no longer accessible after completion. 126 | * If business policy does not allow client secrets to be created on-demand, contact the resource delivering the assessment for instructions to provide a manually created secret when running the prerequisites and collection scripts. A manually provided secret is also stored only in memory when running the script. 127 | 128 | ### Removal of app registration 129 | 130 | You may remove the app registration at the conclusion of the engagement. It is not necessary, however, because it cannot be used without a valid client secret, which is removed when the collection script completes. It is important that you **do not** remove the app registration (or its enterprise application) between the prerequisites installation and the data collection. 131 | -------------------------------------------------------------------------------- /SOA/SOA-Prerequisites.psm1: -------------------------------------------------------------------------------- 1 | #Requires -Version 5.1 2 | #Requires -Modules @{ModuleName="PowerShellGet"; ModuleVersion="2.2.4"} 3 | 4 | <# 5 | 6 | .SYNOPSIS 7 | Prerequisite validation/installation module for Microsoft Security Assessments 8 | 9 | .DESCRIPTION 10 | Contains installation cmdlet which must be run prior to a Microsoft 11 | proactive offering for any of the following security assessments: 12 | - Office 365 Security Optimization Assessment 13 | - Microsoft 365 Foundations: Workload Security Assessment 14 | - Security Optimization Assessment for Microsoft Defender 15 | 16 | The output of the script (JSON file) should be sent to the engineer who will be performing 17 | the assessment. 18 | 19 | ############################################################################ 20 | # This script is not supported under any Microsoft standard support program or service. 21 | # This script is provided AS IS without warranty of any kind. 22 | # Microsoft further disclaims all implied warranties including, without limitation, any implied 23 | # warranties of merchantability or of fitness for a particular purpose. The entire risk arising 24 | # out of the use or performance of the sample script and documentation remains with you. In no 25 | # event shall Microsoft, its authors, or anyone else involved in the creation, production, or 26 | # delivery of the scripts be liable for any damages whatsoever (including, without limitation, 27 | # damages for loss of business profits, business interruption, loss of business information, 28 | # or other pecuniary loss) arising out of the use of or inability to use the sample script or 29 | # documentation, even if Microsoft has been advised of the possibility of such damages. 30 | ############################################################################ 31 | 32 | #> 33 | 34 | Function Get-IsAdministrator { 35 | <# 36 | Determine if the script is running in the context of an administrator or not 37 | #> 38 | 39 | $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) 40 | Return $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) 41 | } 42 | 43 | function Exit-Script { 44 | Remove-Variable -Name subscribedSku -Scope Script -ErrorAction SilentlyContinue 45 | Remove-Variable -Name *Licensed -Scope Script -ErrorAction SilentlyContinue 46 | Remove-Variable -Name ModuleVersions -Scope Script -ErrorAction SilentlyContinue 47 | Stop-Transcript 48 | } 49 | 50 | Function Get-PowerShellCount 51 | { 52 | <# 53 | Returns count of PowerShell windows opened 54 | #> 55 | 56 | $Processes = Get-Process -Name PowerShell 57 | Return $Processes.Count 58 | } 59 | 60 | Function Write-Important { 61 | <# 62 | 63 | Writes IMPORTANT to screen - used at various points during execution 64 | 65 | #> 66 | Write-Host "" 67 | Write-Host "#############################################" -ForegroundColor Yellow 68 | Write-Host "# IMPORTANT #" -ForegroundColor Yellow 69 | Write-Host "#############################################" -ForegroundColor Yellow 70 | Write-Host "" 71 | } 72 | 73 | function New-TemporaryDirectory { 74 | <# 75 | Create a new temporary path for storing files 76 | #> 77 | $parent = [System.IO.Path]::GetTempPath() 78 | [string] $name = [System.Guid]::NewGuid() 79 | $r = New-Item -ItemType Directory -Path (Join-Path $parent $name) 80 | Return $r.FullName 81 | } 82 | 83 | function Get-SOADirectory 84 | { 85 | <# 86 | Gets or creates the SOA directory in AppData 87 | #> 88 | 89 | $Directory = "$($env:LOCALAPPDATA)\Microsoft\SOA" 90 | 91 | If(Test-Path $Directory) 92 | { 93 | Return $Directory 94 | } 95 | else 96 | { 97 | mkdir $Directory | out-null 98 | Return $Directory 99 | } 100 | 101 | } 102 | 103 | function Get-CloudEnvironment { 104 | param ( 105 | $UPN 106 | ) 107 | 108 | $domain = $UPN.Split("@")[1] 109 | try { 110 | $response = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$domain/.well-known/openid-configuration" 111 | } catch { 112 | Write-Verbose "$(Get-Date) Get-CloudEnvironment: Error executing call to get cloud environment for $domain" 113 | Write-Verbose "$(Get-Date) Error: $($_.Exception.Message)" 114 | throw 115 | } 116 | 117 | if ($response.tenant_region_scope) { 118 | if ($response.tenant_region_sub_scope -eq 'GCC') { 119 | Write-Verbose "$(Get-Date) Get-CloudEnvironment: Environment for $domain is USGovGCC" 120 | return 'USGovGCC' 121 | } 122 | if ($response.tenant_region_sub_scope -eq 'DODCON') { 123 | Write-Verbose "$(Get-Date) Get-CloudEnvironment: Environment for $domain is USGovGCCHigh" 124 | return 'USGovGCCHigh' 125 | } 126 | if ($response.tenant_region_sub_scope -eq 'DOD') { 127 | Write-Verbose "$(Get-Date) Get-CloudEnvironment: Environment for $domain is USGovDoD" 128 | return 'USGovDoD' 129 | } 130 | if ($response.cloud_instance_name -eq 'partner.microsoftonline.cn') { 131 | Write-Verbose "$(Get-Date) Get-CloudEnvironment: Environment for $domain is China" 132 | return 'China' 133 | } 134 | Write-Verbose "$(Get-Date) Get-CloudEnvironment: Environment for $domain is Commercial" 135 | return 'Commercial' 136 | } else { 137 | throw 138 | } 139 | 140 | } 141 | 142 | function Get-InitialDomain { 143 | <# 144 | Used during connection tests for Graph SDK and SPO 145 | #> 146 | 147 | # Get the default OnMicrosoft domain. Because the SDK connection is still using a delegated call at this point, the application-based Graph function cannot be used 148 | if ($InitialDomain) { 149 | return $InitialDomain 150 | } else { 151 | $OrgData = (Invoke-MgGraphRequest GET "$GraphHost/v1.0/organization" -OutputType PSObject).Value 152 | return ($OrgData | Select-Object -ExpandProperty VerifiedDomains | Where-Object { $_.isInitial }).Name 153 | } 154 | } 155 | 156 | function Get-SharePointAdminUrl 157 | { 158 | <# 159 | 160 | Used to determine what the SharePoint Admin URL is during connection tests 161 | 162 | #> 163 | Param ( 164 | [string]$CloudEnvironment 165 | ) 166 | 167 | # Custom domain provided for connecting to SPO admin endpoint 168 | if ($SPOAdminDomain) { 169 | $url = "https://" + $SPOAdminDomain 170 | } 171 | else { 172 | $tenantName = ((Get-InitialDomain) -split "\.")[0] 173 | 174 | switch ($CloudEnvironment) { 175 | "Commercial" {$url = "https://" + $tenantName + "-admin.sharepoint.com";break} 176 | "USGovGCC" {$url = "https://" + $tenantName + "-admin.sharepoint.com";break} 177 | "USGovGCCHigh" {$url = "https://" + $tenantName + "-admin.sharepoint.us";break} 178 | "USGovDoD" {$url = "https://" + $tenantName + "-admin.dps.mil";break} 179 | "China" {$url = "https://" + $tenantName + "-admin.sharepoint.cn"} 180 | } 181 | } 182 | return $url 183 | } 184 | 185 | Function Reset-SOAAppSecret { 186 | <# 187 | 188 | This function creates a new secret for the application when the app is retrieved using Invoke-MgGraphRequest from the Microsoft.Graph.Authentication module 189 | 190 | #> 191 | Param ( 192 | $App, 193 | $Task 194 | ) 195 | 196 | # Provision a short lived credential (48 hours) 197 | $Params = @{ 198 | passwordCredential = @{ 199 | displayName = "$Task on $(Get-Date -Format "dd-MMM-yyyy")" 200 | endDateTime = (Get-Date).ToUniversalTime().AddDays(2).ToString("o") 201 | } 202 | } 203 | $Response = Invoke-MgGraphRequest -Method POST -Uri "$GraphHost/v1.0/applications/$($App.Id)/addPassword" -body $Params 204 | 205 | Return $Response.SecretText 206 | } 207 | function Remove-SOAAppSecret { 208 | # Removes any client secrets associated with the application when the app is retrieved using Invoke-MgGraphRequest from the Microsoft.Graph.Authentication module 209 | param () 210 | 211 | # Get application again from Entra to be sure it includes any added secrets 212 | $App = (Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications?`$filter=web/redirectUris/any(p:p eq 'https://security.optimization.assessment.local')&`$count=true" -Headers @{'ConsistencyLevel' = 'eventual'} -OutputType PSObject -ErrorAction SilentlyContinue).Value 213 | 214 | $secrets = $App.passwordCredentials 215 | foreach ($secret in $secrets) { 216 | # Suppress errors in case a secret no longer exists 217 | try { 218 | Invoke-MgGraphRequest -Method POST -Uri "$GraphHost/v1.0/applications(appId=`'$($App.appId)`')/removePassword" -body (ConvertTo-Json -InputObject @{ 'keyId' = $secret.keyId }) #| Out-Null 219 | } 220 | catch {} 221 | } 222 | } 223 | 224 | Function Set-EntraAppPermission { 225 | <# 226 | 227 | Sets the required permissions on the application 228 | 229 | #> 230 | Param( 231 | $App, 232 | $PerformConsent=$False, 233 | [string]$CloudEnvironment 234 | ) 235 | 236 | Write-Host "$(Get-Date) Setting Microsoft Entra enterprise application permissions..." 237 | Write-Verbose "$(Get-Date) Set-EntraAppPermissions App: $($App.Id) Cloud: $CloudEnvironment" 238 | 239 | $RequiredResources = @() 240 | $PermissionSet = $False 241 | $ConsentPerformed = $False 242 | 243 | $Roles = Get-RequiredAppPermissions -CloudEnvironment $CloudEnvironment -HasMDELicense $MDELicensed -HasMDILicense $MDILicensed -HasATPP2License $ATPP2Licensed 244 | 245 | <# 246 | 247 | The following creates a Required Resources array. The array consists of RequiredResourceAccess objects. 248 | There is one RequiredResourceAccess object for every resource; for instance, Graph is a resource. 249 | In the RequiredResourceAccess object is an array of scopes that are required for that resource. 250 | 251 | #> 252 | 253 | foreach($ResourceRolesGrouping in ($Roles | Group-Object Resource)) { 254 | 255 | # Define the resource 256 | $Resource = @{} 257 | 258 | # Add the permissions 259 | ForEach($Role in $($ResourceRolesGrouping.Group)) { 260 | Write-Verbose "$(Get-Date) Set-EntraAppPermissions Add $($Role.Type) $($Role.Name) ($($Role.ID)) in $CloudEnvironment cloud" 261 | $ResourceAccess = @() 262 | $Perm = @{} 263 | $Perm.id = $Role.ID 264 | $Perm.type = $Role.Type 265 | $ResourceAccess += $Perm 266 | 267 | $Resource.resourceAccess += $ResourceAccess 268 | } 269 | $Resource.resourceAppId = $ResourceRolesGrouping.Name 270 | 271 | # Add to the list of required access 272 | $RequiredResources += $Resource 273 | 274 | } 275 | 276 | try { 277 | $Params = @{ 278 | 'requiredResourceAccess' = $RequiredResources 279 | } 280 | 281 | Invoke-MgGraphRequest -Method PATCH -Uri "$GraphHost/v1.0/applications/$($App.Id)" -Body ($Params | ConvertTo-Json -Depth 5) 282 | $PermissionSet = $True 283 | } 284 | catch { 285 | $PermissionSet = $False 286 | } 287 | 288 | if ($PermissionSet -eq $True) { 289 | Write-Host "$(Get-Date) Verifying new permissions applied (this may take up to 5 minutes)..." 290 | If($(Invoke-AppPermissionCheck -App $App -NewPermission) -eq $False) 291 | { 292 | $PermissionSet = $False 293 | } 294 | } 295 | 296 | if ($PerformConsent -eq $True) { 297 | If((Invoke-Consent -App $App -CloudEnvironment $CloudEnvironment) -eq $True) { 298 | $ConsentPerformed = $True 299 | } 300 | } 301 | 302 | If($PermissionSet -eq $True -and $PerformConsent -eq $True -and $ConsentPerformed -eq $True) 303 | { 304 | Return $True 305 | } 306 | ElseIf ($PermissionSet -eq $True -and $PerformConsent -eq $False) 307 | { 308 | Return $True 309 | } 310 | Else 311 | { 312 | Return $False 313 | } 314 | 315 | } 316 | 317 | Function Invoke-AppPermissionCheck 318 | { 319 | <# 320 | Check the permissions are set correctly on the Entra application 321 | #> 322 | Param( 323 | $App, 324 | [Switch]$NewPermission 325 | ) 326 | 327 | $Roles = Get-RequiredAppPermissions -CloudEnvironment $CloudEnvironment -HasMDELicense $MDELicensed -HasMDILicense $MDILicensed -HasATPP2License $ATPP2Licensed 328 | 329 | # In the event of a NewPermission, $MaxTime should be longer to prevent race conditions 330 | If($NewPermission) 331 | { 332 | $MaxTime = 300 333 | } 334 | else 335 | { 336 | $MaxTime = 20 337 | } 338 | 339 | # SleepTime is how long we sleep between checking the permissions 340 | $SleepTime = 10 341 | $Counter = 0 342 | 343 | Write-Verbose "$(Get-Date) Invoke-AppPermissionCheck; App ID $($App.AppId); Role Count $($Roles.Count)" 344 | 345 | While($Counter -lt $MaxTime) 346 | { 347 | $Provisioned = $True 348 | # Refresh roles from Entra 349 | $rCounter = 1 350 | $appId = $app.Id 351 | while ($rCounter -le 5) { 352 | try { 353 | Write-Verbose "$(Get-Date) Getting application from Entra (attempt #$rCounter)" 354 | $App = Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications/$appId" -ErrorAction Stop 355 | break 356 | } 357 | catch { 358 | Write-Verbose "$(Get-Date) Error getting application from Entra, retrying in 5 seconds" 359 | $rCounter++ 360 | Start-Sleep -Seconds 5 361 | } 362 | } 363 | 364 | $Missing = @() 365 | 366 | # Go through each role this app should have, and check if this is in the RequiredResources field for the app 367 | ForEach($Role in $Roles) { 368 | 369 | $RequiredResources = @(($app.RequiredResourceAccess | Where-Object {$_.ResourceAppId -eq $Role.Resource}).ResourceAccess).Id 370 | 371 | If($RequiredResources -notcontains $Role.ID) { 372 | # Role is missing 373 | $Provisioned = $False 374 | $Missing += $Role.Name 375 | } 376 | } 377 | 378 | If($Provisioned -eq $True) 379 | { 380 | Write-Verbose "$(Get-Date) Invoke-AppPermissionCheck; App ID $($app.Id); Role Count $($Roles.Count) OK" 381 | Break 382 | } 383 | Else 384 | { 385 | Write-Verbose "$(Get-Date) Invoke-AppPermissionCheck; App ID $($app.Id); Missing roles: $($Missing -Join ";")" 386 | Start-Sleep $SleepTime 387 | $Counter += $SleepTime 388 | Write-Verbose "$(Get-Date) Invoke-AppPermissionCheck loop - waiting for permissions on Entra application - Counter $Counter maxTime $MaxTime Missing $($Missing -join ' ')" 389 | } 390 | 391 | } 392 | 393 | Return $Provisioned 394 | 395 | } 396 | 397 | 398 | Function Invoke-AppTokenRolesCheckV2 { 399 | <# 400 | 401 | This function checks for the presence of the right scopes on the 402 | Graph connection using the Get-MgContext SDK cmdlet. 403 | Consent may not have been completed without the right roles 404 | 405 | #> 406 | Param ( 407 | [string]$CloudEnvironment 408 | ) 409 | 410 | $Roles = Get-RequiredAppPermissions -CloudEnvironment $CloudEnvironment -HasMDELicense $MDELicensed -HasMDILicense $MDILicensed -HasATPP2License $ATPP2Licensed 411 | 412 | $ActiveScopes = (Get-MgContext).Scopes 413 | $MissingRoles = @() 414 | 415 | ForEach($Role in ($Roles | Where-Object {$_.Resource -eq "00000003-0000-0000-c000-000000000000"})) { 416 | If($ActiveScopes -notcontains $Role.Name) { 417 | Write-Verbose "$(Get-Date) Invoke-AppTokenRolesCheckV2 missing $($Role.Name)" 418 | $MissingRoles += $Role 419 | } 420 | } 421 | 422 | If($MissingRoles.Count -eq 0) { 423 | $return = $true 424 | } Else { 425 | $return = $false 426 | } 427 | 428 | return $return 429 | } 430 | 431 | Function Invoke-Consent { 432 | <# 433 | 434 | Perform consent for application 435 | 436 | #> 437 | Param ( 438 | $App, 439 | [string]$CloudEnvironment 440 | ) 441 | 442 | switch ($CloudEnvironment) { 443 | "Commercial" {$AuthLocBase = "https://login.microsoftonline.com";break} 444 | "USGovGCC" {$AuthLocBase = "https://login.microsoftonline.com";break} 445 | "USGovGCCHigh" {$AuthLocBase = "https://login.microsoftonline.us";break} 446 | "USGovDoD" {$AuthLocBase = "https://login.microsoftonline.us";break} 447 | "China" {$AuthLocBase = "https://login.partner.microsoftonline.cn"} 448 | } 449 | # Need to use the Application ID, not Object ID 450 | $Location = "$AuthLocBase/common/adminconsent?client_id=$($App.AppId)&state=12345&redirect_uri=https://o365soa.github.io/soa/" 451 | Write-Important 452 | Write-Host "In 10 seconds, a page in the default browser will load and ask you to grant consent to Microsoft Security Assessment." 453 | write-Host "You must sign in with an account that has Global Administrator or Privileged Role Administrator role." 454 | Write-Host "After granting consent, a green OK message will appear; you can then close the browser page." 455 | Write-Host "" 456 | Write-Host "For more information about this consent, go to https://github.com/o365soa/soa." 457 | Write-Host "" 458 | Write-Host "If you use single sign-in (SSO) and you are not signed in with an account that has permission to grant consent," 459 | Write-Host "you will need to copy the link and paste it in an private browser session." 460 | Write-Host "" 461 | Write-Host $Location 462 | Write-Host "" 463 | Write-Host "(If the browser window does not open in 10 seconds, copy it and paste it in a browser tab.)" 464 | Write-Host "" 465 | Start-Sleep 10 466 | Start-Process $Location 467 | 468 | While($(Read-Host -Prompt "Type 'yes' when you have completed consent") -ne "yes") {} 469 | 470 | Return $True 471 | } 472 | 473 | Function Install-EntraApp { 474 | <# 475 | 476 | Installs the Entra enterprise application used for accessing Graph and Dynamics 477 | 478 | #> 479 | Param( 480 | [string]$CloudEnvironment 481 | ) 482 | 483 | # Create the Entra application 484 | Write-Verbose "$(Get-Date) Install-EntraApp Installing App" 485 | $Params = @{ 486 | 'displayName' = 'Microsoft 365 Security Assessment' 487 | 'SignInAudience' = 'AzureADMyOrg' 488 | 'web' = @{ 489 | 'redirectUris' = @("https://security.optimization.assessment.local","https://o365soa.github.io/soa/") 490 | } 491 | 'publicClient' = @{ 492 | 'redirectUris' = @("https://login.microsoftonline.com/common/oauth2/nativeclient") 493 | } 494 | } 495 | 496 | $EntraApp = Invoke-MgGraphRequest -Method POST -Uri "$GraphHost/v1.0/applications" -Body $Params 497 | 498 | # Set up the correct permissions 499 | Set-EntraAppPermission -App $EntraApp -PerformConsent:$True -CloudEnvironment $CloudEnvironment 500 | 501 | # Add service principal (enterprise app) as owner of its app registration 502 | $appSp = Get-SOAAppServicePrincipal -EntraApp $EntraApp 503 | if ($appSp) { 504 | if (Add-SOAAppOwner -NewOwnerObjectId $appSp.Id -EntraApp $EntraApp) { 505 | $script:appSelfOwner = $true 506 | } else { 507 | $script:appSelfOwner = $false 508 | } 509 | } else { 510 | $script:appSelfOwner = $false 511 | } 512 | 513 | # Return the newly created application 514 | return (Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications/$($EntraApp.Id)") 515 | 516 | } 517 | 518 | Function Get-ModuleStatus { 519 | <# 520 | 521 | Determines the status of the module specified by ModuleName 522 | 523 | #> 524 | Param ( 525 | [String]$ModuleName, 526 | [Switch]$ConflictModule 527 | ) 528 | 529 | Write-Host "$(Get-Date) Checking module $($ModuleName)" 530 | 531 | # Set variables used 532 | $MultipleFound = $False 533 | $Installed = $False 534 | $Arguments = @{} 535 | 536 | # Evaluate the ModuleVersion JSON file to determine if any versions should be excluded 537 | $MaxVersion = ($script:ModuleVersions | Where-Object {$_.ModuleName -eq $ModuleName}).MaximumVersion 538 | 539 | if ($MaxVersion) { 540 | Write-Verbose "A MaximumVersion of $MaxVersion was specified for $ModuleName. Only this version will be installed." 541 | 542 | # Splat the arguments when using Find-Module 543 | $Arguments = @{ 544 | MaximumVersion = $MaxVersion 545 | } 546 | 547 | $InstalledModule = @(Get-Module -Name $ModuleName -ListAvailable | Where-Object {$_.Version -le $MaxVersion}) 548 | } else { 549 | $InstalledModule = @(Get-Module -Name $ModuleName -ListAvailable) 550 | } 551 | 552 | ForEach($M in $InstalledModule) 553 | { 554 | Write-Verbose "$(Get-Date) Get-ModuleStatus $ModuleName Version $($M.Version.ToString()) Path $($M.Path)" 555 | } 556 | 557 | $modulePaths = @() 558 | foreach ($m in ($InstalledModule | Sort-Object Version -Desc)) { 559 | $modulePaths += $m.Path.Substring(0,$m.Path.LastIndexOf('\')) 560 | } 561 | 562 | If($InstalledModule.Count -gt 1) { 563 | # More than one module, flag this 564 | $MultipleFound = $True 565 | $Installed = $True 566 | 567 | # Use the latest for comparisons 568 | $InstalledModule = ($InstalledModule | Sort-Object Version -Desc)[0] 569 | } ElseIf($InstalledModule.Count -eq 1) { 570 | # Only one installed 571 | $Installed = $True 572 | } 573 | 574 | $PSGalleryModule = @(Find-Module $ModuleName -ErrorAction:SilentlyContinue @Arguments) 575 | 576 | If($PSGalleryModule.Count -eq 1) { 577 | [version]$GalleryVersion = $PSGalleryModule.Version 578 | If($GalleryVersion -gt $InstalledModule.Version) { 579 | $NewerAvailable = $true 580 | } Else { 581 | $NewerAvailable = $false 582 | } 583 | } 584 | 585 | Write-Verbose "$(Get-Date) Get-ModuleStatus $ModuleName Verdict Installed $($Installed) InstalledV $($InstalledModule.Version) GalleryV $($GalleryVersion) Multiple $($Multiple) NewerAvailable $($NewerAvailable)" 586 | 587 | Return New-Object -TypeName PSObject -Property @{ 588 | Module=$ModuleName 589 | InstalledVersion=$InstalledModule.Version 590 | GalleryVersion=$(if ($MaxVersion) { "$GalleryVersion (*)" } else { $GalleryVersion }) 591 | Installed=$Installed 592 | Conflict=$(if ($Installed -and $ConflictModule) { $True } else { $False }) 593 | Multiple=$MultipleFound 594 | Path=$modulePaths 595 | NewerAvailable=$NewerAvailable 596 | } 597 | 598 | } 599 | 600 | Function Uninstall-OldModules { 601 | <# 602 | 603 | Removes old versions of a module 604 | 605 | #> 606 | Param( 607 | $Module 608 | ) 609 | 610 | $Modules = (Get-Module $Module -ListAvailable | Sort-Object Version -Descending) 611 | $Latest = $Modules[0] 612 | 613 | If($Modules.Count -gt 1) { 614 | ForEach($Module in $Modules) { 615 | If($Module.Version -ne $Latest.Version) { 616 | # Not the latest version, remove it. 617 | Write-Host "$(Get-Date) Uninstalling $($Module.Name) Version $($Module.Version)" 618 | Try { 619 | Uninstall-Module $Module.Name -RequiredVersion $($Module.Version) -ErrorAction:Stop 620 | } Catch { 621 | # Some code needs to be placed here to catch possible error. 622 | } 623 | 624 | } 625 | } 626 | } 627 | } 628 | 629 | Function Remove-FromPSModulePath { 630 | <# 631 | 632 | Remove from PSModulePath 633 | 634 | This module removes paths from the PSModulePath. It can be used to 'uninstall' the manual installation 635 | of the SharePoint module, for instance. 636 | 637 | #> 638 | Param( 639 | $Folder 640 | ) 641 | 642 | $PathArray = (Get-ChildItem Env:PSModulePath).Value.Split(";") 643 | 644 | If($PathArray -Contains $Folder) { 645 | Write-Host "$(Get-Date) Removing $Folder from PSModulePath" 646 | $NewPathArray = $PathArray | Where-Object {$_ -ne $Folder} 647 | Set-Item Env:PSModulePath -Value ($NewPathArray -Join ";") 648 | Return $True 649 | } Else { 650 | Write-Error "Attempted to remove $Folder from PSModulePath, however, there is no entry. PSModulePath is $((Get-ChildItem Env:PSModulePath).Value)" 651 | Return $False 652 | } 653 | 654 | } 655 | 656 | Function Get-PSModulePath { 657 | <# 658 | 659 | Gets PSModulePath using a like condition. 660 | This is used for determining if a module is manually installed, and can be used for removing that manual installation. 661 | 662 | #> 663 | Param ( 664 | $LikeCondition 665 | ) 666 | 667 | $PathArray = (Get-ChildItem Env:PSModulePath).Value.Split(";") 668 | $Return = @($PathArray | Where-Object {$_ -like $LikeCondition}) 669 | 670 | Return $Return 671 | 672 | } 673 | 674 | function Get-LicenseStatus { 675 | param ($LicenseType) 676 | $resources = (Get-Content -Path (Join-Path -Path $MyInvocation.MyCommand.Module.ModuleBase -ChildPath resources.json) | ConvertFrom-Json) 677 | if ($LicenseType -eq 'Teams') { 678 | $targetSkus = ($resources.Sku.$LicenseType.Default + $resources.Sku.$LicenseType.Custom) | Where-Object {$_ -match "[a-z]+"} 679 | } elseif ($LicenseType -eq 'AADP1' -or $LicenseType -eq 'AADP2' -or $LicenseType -eq 'ATPP2' -or $LicenseType -eq 'MDE' -or $LicenseType -eq 'MDI') { 680 | $targetSkus = $resources.Sku.$LicenseType | Where-Object {$_ -match "[a-z]+"} 681 | } else { 682 | Write-Error "$(Get-Date) Get-LicenseStatus: $LicenseType`: Invalid " 683 | return $false 684 | } 685 | 686 | #Get SKUs only if not already retrieved 687 | if (-not $subscribedSku) { 688 | Write-Verbose "$(Get-Date) Get-LicenseStatus: Getting subscribed SKUs" 689 | $script:subscribedSku = Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/subscribedSkus" -OutputType PSObject 690 | } 691 | if ($LicenseType -eq "Teams") { 692 | # Teams license check is handled differently because it needs to be an exact match 693 | if ($HasTeamsLicense) { 694 | Write-Verbose "$(Get-Date) Get-LicenseStatus HasTeamsLicense switch used, skipping license check and returning True" 695 | Write-Verbose "$(Get-Date) Get-LicenseStatus $LicenseType`: True " 696 | return $true 697 | } 698 | foreach ($tSku in $targetSkus) { 699 | foreach ($sku in $subscribedSku.value) { 700 | if ($sku.prepaidUnits.enabled -gt 0 -or $sku.prepaidUnits.warning -gt 0 -and $sku.skuPartNumber -eq $tSku) { 701 | Write-Verbose "$(Get-Date) Get-LicenseStatus $LicenseType`: True Matched $($sku.skuPartNumber)" 702 | return $true 703 | } 704 | } 705 | } 706 | } else { 707 | if ($LicenseType -eq "AADP1" -and $HasEntraP1License) { 708 | Write-Verbose "$(Get-Date) Get-LicenseStatus HasEntraP1License switch used, skipping license check and returning True" 709 | Write-Verbose "$(Get-Date) Get-LicenseStatus $LicenseType`: True " 710 | return $true 711 | } elseif ($LicenseType -eq "AADP2" -and $HasEntraP2License) { 712 | Write-Verbose "$(Get-Date) Get-LicenseStatus HasEntraP2License switch used, skipping license check and returning True" 713 | Write-Verbose "$(Get-Date) Get-LicenseStatus $LicenseType`: True " 714 | return $true 715 | } elseif ($LicenseType -eq "ATPP2" -and $HasMDOP2License) { 716 | Write-Verbose "$(Get-Date) Get-LicenseStatus HasMDOP2License switch used, skipping license check and returning True" 717 | Write-Verbose "$(Get-Date) Get-LicenseStatus: $LicenseType`: True " 718 | return $true 719 | } elseif ($LicenseType -eq "MDE" -and $HasMDELicense) { 720 | Write-Verbose "$(Get-Date) Get-LicenseStatus HasMDELicense switch used, skipping license check and returning True" 721 | Write-Verbose "$(Get-Date) Get-LicenseStatus: $LicenseType`: True " 722 | return $true 723 | } elseif ($LicenseType -eq "MDI" -and $HasMDILicense) { 724 | Write-Verbose "$(Get-Date) Get-LicenseStatus HasMDILicense switch used, skipping license check and returning True" 725 | Write-Verbose "$(Get-Date) Get-LicenseStatus: $LicenseType`: True " 726 | return $true 727 | } 728 | foreach ($tSku in $targetSkus) { 729 | foreach ($sku in $subscribedSku.value) { 730 | if ($sku.prepaidUnits.enabled -gt 0 -or $sku.prepaidUnits.warning -gt 0 -and $sku.skuPartNumber -match $tSku) { 731 | Write-Verbose "$(Get-Date) Get-LicenseStatus: $LicenseType`: True Matched $($sku.skuPartNumber)" 732 | return $true 733 | } 734 | } 735 | } 736 | } 737 | Write-Verbose "$(Get-Date) Get-LicenseStatus: $LicenseType`: False " 738 | return $false 739 | } 740 | 741 | Function Install-ModuleFromGallery { 742 | <# 743 | 744 | Updates module from PSGallery 745 | 746 | #> 747 | Param( 748 | $Module, 749 | [Switch]$Update 750 | ) 751 | 752 | $InstallArguments = @{} 753 | 754 | # Install the module from PSGallery specifying Force 755 | # AllowClobber allows Teams module to be installed when SfBO module is installed/loaded 756 | if (Get-IsAdministrator) { 757 | $InstallArguments = @{ 758 | Scope = "AllUsers" 759 | } 760 | } 761 | else { 762 | $InstallArguments = @{ 763 | Scope = "CurrentUser" 764 | } 765 | } 766 | 767 | $MaxVersion = ($script:ModuleVersions | Where-Object {$_.ModuleName -eq $Module}).MaximumVersion 768 | if ($MaxVersion) { 769 | Write-Verbose "A MaximumVersion of $MaxVersion was specified for $Module. Only this version will be installed from the PSGallery." 770 | 771 | $InstallArguments.Add("RequiredVersion",$MaxVersion) 772 | } 773 | 774 | Install-Module $Module -Force -AllowClobber @InstallArguments 775 | 776 | If($Update) { 777 | # Remove old versions of the module 778 | Uninstall-OldModules -Module $Module 779 | } 780 | } 781 | 782 | Function Install-ADDSModule { 783 | <# 784 | 785 | Installs the on-prem Active Directory module based on the detected OS version 786 | 787 | #> 788 | 789 | if (Get-IsAdministrator) { 790 | $ComputerInfo = Get-ComputerInfo 791 | 792 | If($ComputerInfo) { 793 | Write-Verbose "Computer type: $($ComputerInfo.WindowsInstallationType)" 794 | Write-Verbose "OS Build: $($ComputerInfo.OsBuildNumber)" 795 | If ($ComputerInfo.WindowsInstallationType -eq "Server") { 796 | Write-Verbose "Server OS detected, using 'Add-WindowsFeature'" 797 | Try { 798 | Add-WindowsFeature -Name RSAT-AD-PowerShell -IncludeAllSubFeature | Out-Null 799 | } Catch { 800 | Write-Error "$(Get-Date) Could not install ActiveDirectory module" 801 | } 802 | } 803 | ElseIf ($ComputerInfo.WindowsInstallationType -eq "Client" -And $ComputerInfo.OsBuildNumber -ge 17763) { 804 | Write-Verbose "Windows 10 version 1809 or later detected, using 'Add-WindowsCapability'" 805 | Try { 806 | Add-WindowsCapability -Online -Name "Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0" | Out-Null 807 | } Catch { 808 | Write-Error "$(Get-Date) Could not install ActiveDirectory module. Is -UseProxy needed? If configured for WSUS, you will need to deploy the module from there." 809 | } 810 | } 811 | ElseIf ($ComputerInfo.WindowsInstallationType -eq "Client") { 812 | Write-Verbose "Windows 10 version 1803 or earlier detected, using 'Enable-WindowsOptionalFeature'" 813 | Try { 814 | Enable-WindowsOptionalFeature -Online -FeatureName RSATClient-Roles-AD-Powershell | Out-Null 815 | } Catch { 816 | Write-Error "$(Get-Date) Could not install ActiveDirectory module. Is -UseProxy needed? If configured for WSUS, you will need to deploy the module from there or install from https://www.microsoft.com/en-us/download/details.aspx?id=45520." 817 | } 818 | } 819 | Else { 820 | Write-Error "Error detecting the OS type while installing Active Directory module." 821 | } 822 | } 823 | } 824 | else { 825 | Exit-Script 826 | throw "$(Get-Date) You must be running PowerShell as an administrator in order to install the Active Directory module." 827 | } 828 | } 829 | 830 | Function Invoke-ModuleFix { 831 | <# 832 | 833 | Attempts to fix modules if $Remediate flag is specified 834 | 835 | #> 836 | Param($Modules) 837 | 838 | $OutdatedModules = $Modules | Where-Object {$null -ne $_.InstalledVersion -and $_.NewerAvailable -eq $true -and $_.Conflict -ne $True} 839 | # Administrator needed to remove modules in other profiles 840 | if ($RemoveMultipleModuleVersions) { 841 | if (Get-IsAdministrator) { 842 | $DupeModules = $Modules | Where-Object {$_.Multiple -eq $True} 843 | } 844 | else { 845 | Exit-Script 846 | throw "Start PowerShell as an administrator to be able to uninstall multiple versions of modules." 847 | return $False 848 | } 849 | } 850 | $MissingGalleryModules = $Modules | Where-Object {$null -eq $_.InstalledVersion -and $null -ne $_.GalleryVersion } 851 | $ConflictModules = $Modules | Where-Object {$_.Conflict -eq $True} 852 | $MissingNonGalleryModules = $Modules | Where-Object {$null -eq $_.InstalledVersion -and $null -eq $_.GalleryVersion} 853 | 854 | # Determine status of PSGallery repository 855 | $PSGallery = Get-PSRepository -Name "PSGallery" 856 | If($PSGallery) { 857 | If($PSGallery.InstallationPolicy -eq "Untrusted") { 858 | # Untrusted PSGallery, set to trust 859 | Write-Host "$(Get-Date) Trusting PSGallery for remediation activities" 860 | Try { 861 | Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted -ErrorAction Stop 862 | } Catch { 863 | Exit-Script 864 | throw "$(Get-Date) Unable to set PSGallery as trusted" 865 | 866 | } 867 | } 868 | } Else { 869 | Exit-Script 870 | throw "PSGallery is not present on this host, so modules cannot be installed." 871 | 872 | } 873 | 874 | # Conflict modules, need to be removed 875 | ForEach($ConflictModule in $ConflictModules) { 876 | Write-Host "$(Get-Date) Removing conflicting module $($ConflictModule.Module)" 877 | Uninstall-Module -Name $($ConflictModule.Module) -Force 878 | } 879 | 880 | # Out of date modules 881 | ForEach($OutdatedModule in $OutdatedModules) { 882 | Write-Host "$(Get-Date) Installing version $($OutdatedModule.GalleryVersion) of $($OutdatedModule.Module) (highest installed version is $($OutdatedModule.InstalledVersion))" 883 | if ($RemoveMultipleModuleVersions) { 884 | Install-ModuleFromGallery -Module $($OutdatedModule.Module) -Update 885 | } 886 | else { 887 | Install-ModuleFromGallery -Module $($OutdatedModule.Module) 888 | } 889 | } 890 | 891 | # Missing gallery modules 892 | ForEach($MissingGalleryModule in $MissingGalleryModules) { 893 | Write-Host "$(Get-Date) Installing $($MissingGalleryModule.Module)" 894 | Install-ModuleFromGallery -Module $($MissingGalleryModule.Module) 895 | } 896 | 897 | # Dupe modules 898 | ForEach($DupeModule in $DupeModules) { 899 | Write-Host "$(Get-Date) Removing older versions of modules for $($DupeModule.Module)" 900 | Uninstall-OldModules -Module $($DupeModule.Module) 901 | } 902 | 903 | # Missing modules which are not available from gallery 904 | ForEach($MissingNonGalleryModule in $MissingNonGalleryModules) { 905 | Write-Host "$(Get-Date) Installing $($MissingNonGalleryModule.Module)" 906 | 907 | Switch ($MissingNonGalleryModule.Module) { 908 | "ActiveDirectory" { 909 | Write-Verbose "$(Get-Date) Installing on-premises Active Directory module" 910 | Install-ADDSModule 911 | } 912 | } 913 | } 914 | } 915 | 916 | Function Get-ManualModules 917 | { 918 | <# 919 | 920 | Determines if there are any manual module installs as opposed to PowerShell Gallery installs 921 | 922 | #> 923 | Param( 924 | [Switch]$Remediate 925 | ) 926 | 927 | $Return = @() 928 | 929 | $ModuleChecks = @("SharePoint Online Management Shell") 930 | 931 | ForEach($ModuleCheck in $ModuleChecks) 932 | { 933 | $RemediateSuccess = $False 934 | 935 | $Result = Get-PSModulePath -LikeCondition "*$($ModuleCheck)*" 936 | 937 | If($Remediate) 938 | { 939 | ForEach ($r in $Result) 940 | { 941 | $RemediateSuccess = Remove-FromPSModulePath -Folder $r 942 | } 943 | } 944 | 945 | If($Result.Count -gt 0 -and $RemediateSuccess -eq $False) { 946 | $Return += $ModuleCheck 947 | } 948 | } 949 | 950 | Return $Return 951 | 952 | } 953 | 954 | Function Invoke-SOAModuleCheck { 955 | 956 | $RequiredModules = @() 957 | 958 | # Conflict modules are modules which their presence causes issues 959 | $ConflictModules = @() 960 | 961 | # Bypass checks 962 | if ($Bypass -notcontains "SPO") { $RequiredModules += "Microsoft.Online.SharePoint.PowerShell" } 963 | if ($Bypass -notcontains "Teams") {$RequiredModules += "MicrosoftTeams"} 964 | if (($Bypass -notcontains "EXO" -or $Bypass -notcontains "SCC")) {$RequiredModules += "ExchangeOnlineManagement"} 965 | if ($Bypass -notcontains "PP") {$RequiredModules += "Microsoft.PowerApps.Administration.PowerShell"} 966 | if ($Bypass -notcontains "Graph") {$RequiredModules += "Microsoft.Graph.Authentication"} 967 | if ($Bypass -notcontains "ActiveDirectory") { $RequiredModules += "ActiveDirectory" } 968 | 969 | $ModuleCheckResult = @() 970 | 971 | ForEach($m in $RequiredModules) { 972 | $ModuleCheckResult += (Get-ModuleStatus -ModuleName $m) 973 | } 974 | 975 | ForEach($m in $ConflictModules) { 976 | $MInfo = (Get-ModuleStatus -ModuleName $m -ConflictModule) 977 | If($MInfo.Installed -eq $True) { 978 | $ModuleCheckResult += $MInfo 979 | } 980 | } 981 | 982 | Return $ModuleCheckResult 983 | } 984 | 985 | function Import-PSModule { 986 | param ( 987 | $ModuleName, 988 | [switch]$Implicit 989 | ) 990 | 991 | if ($Implicit -eq $false) { 992 | # Evaluate the ModuleVersion JSON file to determine if any versions should be excluded 993 | $MaxVersion = ($script:ModuleVersions | Where-Object {$_.ModuleName -eq $ModuleName}).MaximumVersion 994 | 995 | if ($MaxVersion) { 996 | Write-Verbose "A MaximumVersion of $MaxVersion was specified for $ModuleName. Only this version will be imported." 997 | 998 | $highestVersion = (Get-Module -Name $ModuleName -ListAvailable | Where-Object {$_.Version -le $MaxVersion} | Sort-Object -Property Version -Descending | Select-Object -First 1).Version.ToString() 999 | } else { 1000 | $highestVersion = (Get-Module -Name $ModuleName -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1).Version.ToString() 1001 | } 1002 | 1003 | # Multiple loaded versions are listed in reverse order of precedence 1004 | $loadedModule = Get-Module -Name $ModuleName | Select-Object -Last 1 1005 | if ($loadedModule -and $loadedModule.Version.ToString() -ne $highestVersion) { 1006 | # Unload module if the highest version isn't loaded or not highest precedence 1007 | Write-Verbose -Message "Version $($loadedModule.Version.ToString()) of $ModuleName is loaded, but the highest installed version is $highestVersion. The module will be unloaded and the highest version loaded." 1008 | Remove-Module -Name $ModuleName 1009 | } 1010 | if ($ModuleName -eq 'Microsoft.PowerApps.Administration.PowerShell') { 1011 | # Explicitly load its auth module using SilentlyContinue to suppress warnings due to Recover-* being an unapproved verb in the Auth module 1012 | $PAPath = (Get-Module -Name $ModuleName -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).ModuleBase 1013 | Import-Module (Join-Path -Path $PAPath "Microsoft.PowerApps.AuthModule.psm1") -WarningAction:SilentlyContinue -Force 1014 | } 1015 | Import-Module -Name $ModuleName -RequiredVersion $highestVersion -ErrorVariable loadError -Force -WarningAction SilentlyContinue 1016 | if ($loadError) { 1017 | Write-Error -Message "Error loading module $ModuleName." 1018 | } 1019 | 1020 | # Check that Graph modules have dependent module (Authentication) loaded with the same version and throw an error if they are not the same version. Only check for non-Auth modules since they will have a RequiredModules statement in the manifest to load the Auth module 1021 | if ($ModuleName -like 'Microsoft.Graph.*' -and $ModuleName -ne 'Microsoft.Graph.Authentication'){ 1022 | $GraphModule = Get-Module -Name $ModuleName | Sort-Object Version -Descending 1023 | $AuthModule = Get-Module -Name 'Microsoft.Graph.Authentication' | Sort-Object Version -Descending 1024 | 1025 | If (($GraphModule).Version -ne ($AuthModule).Version) { 1026 | Write-Error "The version for loaded modules $ModuleName ($($GraphModule.Version.ToString())) and Microsoft.Graph.Authentication ($($AuthModule.Version.ToString())) are not matching and will cause calls to Microsoft Graph to fail. Run `"Install-SOAPrerequisites -ModulesOnly`" to ensure the latest version of all required Microsoft.Graph modules is installed. If the latest version is installed, open a new PowerShell window." 1027 | Exit-Script 1028 | } 1029 | } 1030 | } 1031 | } 1032 | Function Test-Connections { 1033 | Param( 1034 | $RPSProxySetting, 1035 | [string]$CloudEnvironment 1036 | ) 1037 | 1038 | $Connections = @() 1039 | 1040 | Write-Host "$(Get-Date) Testing connections..." 1041 | #$userUPN = Read-Host -Prompt "What is the UPN of the admin account that you will be signing in with for connection validation and with sufficient privileges to register the Microsoft Entra enterprise application" 1042 | 1043 | <# 1044 | 1045 | Graph PowerShell SDK 1046 | 1047 | #> 1048 | $connectToGraph = $false 1049 | $Connect = $False; $ConnectError = $Null; $Command = $False; $CommandError = $Null 1050 | # Teams and SPO connections are dependent on Graph connection to determine Teams service plans and to get initial domain 1051 | if ($Bypass -notcontains 'Teams' -or $Bypass -notcontains 'SPO' ) { 1052 | if ($Bypass -contains 'Graph') { 1053 | Write-Warning -Message "Even though Graph is bypassed, Teams and/or SPO are not bypassed and require Graph. Therefore, the Graph connection will still occur." 1054 | $connectToGraph = $true 1055 | } 1056 | } 1057 | if ($Bypass -notcontains 'Graph') { 1058 | $connectToGraph = $true 1059 | } 1060 | if ($connectToGraph -eq $true) { 1061 | Import-PSModule -ModuleName Microsoft.Graph.Authentication -Implicit:$UseImplicitLoading 1062 | switch ($CloudEnvironment) { 1063 | "Commercial" {$cloud = 'Global'} 1064 | "USGovGCC" {$cloud = 'Global'} 1065 | "USGovGCCHigh" {$cloud = 'USGov'} 1066 | "USGovDoD" {$cloud = 'USGovDoD'} 1067 | "China" {$cloud = 'China'} 1068 | } 1069 | $ConnContext = (Get-MgContext).Scopes 1070 | if (($ConnContext -notcontains 'Application.ReadWrite.All' -and $PromptForApplicationSecret -eq $False) -or ($ConnContext -notcontains 'Application.Read.All' -and $PromptForApplicationSecret -eq $True) -or ($ConnContext -notcontains 'Organization.Read.All' -and $ConnContext -notcontains 'Directory.Read.All')) { 1071 | Write-Host "$(Get-Date) Connecting to Microsoft Graph (with delegated authentication)..." 1072 | if ($null -ne (Get-MgContext)){Disconnect-MgGraph | Out-Null} 1073 | $connCount = 0 1074 | $connLimit = 5 1075 | do { 1076 | try { 1077 | $connCount++ 1078 | Write-Verbose "$(Get-Date) Test-Connections: Graph Delegated connection attempt #$connCount" 1079 | # User.Read is sufficient for using the organization API to get the domain for the Teams/SPO connections 1080 | # Using Organization.Read.All because that is the least-common scope for getting licenses in the app check 1081 | 1082 | if ($CloudEnvironment -eq "China") { 1083 | # Connections to 21Vianet must have provided the ClientID and Tenant manually 1084 | if (-not $GraphClientId -or -not $InitialDomain ) { 1085 | Exit-Script 1086 | throw "$(Get-Date) Connections to Graph in 21Vianet require the application ID (client ID) and tenant name (initial domain) be manually provided. Use both `-GraphClientId` and `-InitialDomain` parameters to provide them. For more information, see https://github.com/o365soa/soa." 1087 | } 1088 | Connect-MgGraph -Scopes 'Application.ReadWrite.All','Organization.Read.All' -Environment $cloud -ContextScope "Process" -ClientId $GraphClientId -Tenant $InitialDomain -NoWelcome -ErrorVariable ConnectError| Out-Null 1089 | } elseif ($PromptForApplicationSecret) { 1090 | # Request read-only permissions to Graph if manually providing the client secret 1091 | Connect-MgGraph -Scopes 'Application.Read.All','Organization.Read.All' -Environment $cloud -ContextScope "Process" -NoWelcome -ErrorVariable ConnectError | Out-Null 1092 | } else { 1093 | Connect-MgGraph -Scopes 'Application.ReadWrite.All','Organization.Read.All' -Environment $cloud -ContextScope "Process" -NoWelcome -ErrorVariable ConnectError | Out-Null 1094 | } 1095 | } 1096 | catch { 1097 | Write-Verbose $_ 1098 | Start-Sleep 1 1099 | } 1100 | } 1101 | until ($null -ne (Get-MgContext) -or $connCount -eq $connLimit) 1102 | if ($null -eq (Get-MgContext)) { 1103 | Write-Error -Message "Unable to connect to Graph. Skipping dependent connection tests." 1104 | $Connect = $False 1105 | } 1106 | else { 1107 | $Connect = $True 1108 | $GraphSDKConnected = $true 1109 | } 1110 | } else { 1111 | Write-Host "$(Get-Date) Connecting to Microsoft Graph (with delegated authentication)..." -NoNewline 1112 | Write-Host " Already connected (Run Disconnect-MgGraph if you want to reconnect to Graph)" -ForegroundColor Green 1113 | $Connect = $True 1114 | $GraphSDKConnected = $true 1115 | } 1116 | if ($Connect -eq $true) { 1117 | $org = (Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/organization" -OutputType PSObject -ErrorAction SilentlyContinue -ErrorVariable CommandError).Value 1118 | if ($org.id) {$Command = $true} else {$Command = $false} 1119 | } 1120 | 1121 | $Connections += New-Object -TypeName PSObject -Property @{ 1122 | Name="GraphSDK" 1123 | Connected=$Connect 1124 | ConnectErrors=$ConnectError 1125 | TestCommand=$Command 1126 | TestCommandErrors=$CommandError 1127 | } 1128 | } 1129 | 1130 | 1131 | <# 1132 | 1133 | SCC 1134 | 1135 | #> 1136 | if ($Bypass -notcontains 'SCC' -or $Bypass -notcontains 'EXO') { 1137 | Import-PSModule -ModuleName ExchangeOnlineManagement -Implicit:$UseImplicitLoading 1138 | } 1139 | 1140 | If($Bypass -notcontains "SCC") { 1141 | # Reset vars 1142 | $Connect = $False; $ConnectError = $Null; $Command = $False; $CommandError = $Null 1143 | 1144 | Write-Host "$(Get-Date) Connecting to SCC..." 1145 | # Commented Jan 3, 2025 because don't know that the connection removal is necessary anymore 1146 | # Removing existing connections in case any use a prefix 1147 | # Get-ConnectionInformation | Where-Object {$_.ConnectionUri -like "*protection.o*" -or $_.ConnectionUri -like "*protection.partner.o*"} | ForEach-Object {Disconnect-ExchangeOnline -ConnectionId $_.ConnectionId -Confirm:$false} 1148 | if ($NoAdminUPN) { 1149 | switch ($CloudEnvironment) { 1150 | "Commercial" {Connect-IPPSSession -PSSessionOption $ProxySetting -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1151 | "USGovGCC" {Connect-IPPSSession -PSSessionOption $ProxySetting -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1152 | "USGovGCCHigh" {Connect-IPPSSession -ConnectionUri https://ps.compliance.protection.office365.us/PowerShell-LiveID -AzureADAuthorizationEndPointUri https://login.microsoftonline.us/common -PSSessionOption $ProxySetting -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1153 | "USGovDoD" {Connect-IPPSSession -ConnectionUri https://l5.ps.compliance.protection.office365.us/PowerShell-LiveID -AzureADAuthorizationEndPointUri https://login.microsoftonline.us/common -PSSessionOption $ProxySetting -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1154 | "China" {Connect-IPPSSession -ConnectionUri https://ps.compliance.protection.partner.outlook.cn/PowerShell-LiveID -AzureADAuthorizationEndPointUri https://login.partner.microsoftonline.cn/common -PSSessionOption $ProxySetting -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1155 | } 1156 | } else { 1157 | switch ($CloudEnvironment) { 1158 | "Commercial" {Connect-IPPSSession -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -WarningAction SilentlyContinue -ShowBanner:$False | Out-Null} 1159 | "USGovGCC" {Connect-IPPSSession -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -WarningAction SilentlyContinue -ShowBanner:$False | Out-Null} 1160 | "USGovGCCHigh" {Connect-IPPSSession -ConnectionUri https://ps.compliance.protection.office365.us/PowerShell-LiveID -AzureADAuthorizationEndPointUri https://login.microsoftonline.us/common -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1161 | "USGovDoD" {Connect-IPPSSession -ConnectionUri https://l5.ps.compliance.protection.office365.us/PowerShell-LiveID -AzureADAuthorizationEndPointUri https://login.microsoftonline.us/common -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1162 | "China" {Connect-IPPSSession -ConnectionUri https://ps.compliance.protection.partner.outlook.cn/PowerShell-LiveID -AzureADAuthorizationEndPointUri https://login.partner.microsoftonline.cn/common -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -WarningAction SilentlyContinue -ErrorVariable ConnectError -ShowBanner:$False | Out-Null} 1163 | } 1164 | } 1165 | 1166 | If((Get-ConnectionInformation | Where-Object {$_.ConnectionUri -like "*protection.o*" -or $_.ConnectionUri -like "*protection.partner.o*"}).State -eq "Connected") { $Connect = $True } Else { $Connect = $False } 1167 | 1168 | # Has test command been imported. Not actually running it 1169 | # Cmdlet available to any user 1170 | if (Get-Command Get-Recipient) { 1171 | # Cmdlet available to admins 1172 | #If(Get-Command "Get-ProtectionAlert") { 1173 | $Command = $True 1174 | } Else { 1175 | $Command = $False 1176 | } 1177 | 1178 | $Connections += New-Object -TypeName PSObject -Property @{ 1179 | Name="SCC" 1180 | Connected=$Connect 1181 | ConnectErrors=$ConnectError 1182 | TestCommand=$Command 1183 | TestCommandErrors=$CommandError 1184 | } 1185 | } 1186 | 1187 | <# 1188 | 1189 | Exchange 1190 | 1191 | #> 1192 | If($Bypass -notcontains "EXO") { 1193 | # Reset vars 1194 | $Connect = $False; $ConnectError = $Null; $Command = $False; $CommandError = $Null 1195 | 1196 | Write-Host "$(Get-Date) Connecting to Exchange..." 1197 | if ($NoAdminUPN) { 1198 | switch ($CloudEnvironment) { 1199 | "Commercial" {Connect-ExchangeOnline -PSSessionOption $ProxySetting -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1200 | "USGovGCC" {Connect-ExchangeOnline -PSSessionOption $ProxySetting -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1201 | "USGovGCCHigh" {Connect-ExchangeOnline -ExchangeEnvironmentName O365USGovGCCHigh -PSSessionOption $ProxySetting -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1202 | "USGovDoD" {Connect-ExchangeOnline -ExchangeEnvironmentName O365USGovDoD -PSSessionOption $ProxySetting -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1203 | "China" {Connect-ExchangeOnline -ExchangeEnvironmentName O365China -PSSessionOption $ProxySetting -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1204 | } 1205 | } 1206 | else { 1207 | switch ($CloudEnvironment) { 1208 | "Commercial" {Connect-ExchangeOnline -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1209 | "USGovGCC" {Connect-ExchangeOnline -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1210 | "USGovGCCHigh" {Connect-ExchangeOnline -ExchangeEnvironmentName O365USGovGCCHigh -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1211 | "USGovDoD" {Connect-ExchangeOnline -ExchangeEnvironmentName O365USGovDoD -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1212 | "China" {Connect-ExchangeOnline -ExchangeEnvironmentName O365China -PSSessionOption $ProxySetting -UserPrincipalName $AdminUPN -ShowBanner:$false -WarningAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1213 | } 1214 | } 1215 | 1216 | If((Get-ConnectionInformation | Where-Object {$_.ConnectionUri -like "*outlook.office*" -or $_.ConnectionUri -like "*webmail.apps.mil*" -or $_.ConnectionUri -like "*partner.outlook.cn*"}).TokenStatus -eq "Active") { $Connect = $True } Else { $Connect = $False } 1217 | 1218 | # Has test command been imported. Not actually running it 1219 | # Cmdlet available to any user 1220 | if (Get-Command Get-Mailbox) { 1221 | # Cmdlet available to admin 1222 | #If(Get-Command "Get-OrganizationConfig") { 1223 | If((Get-OrganizationConfig).Name) { 1224 | $Command = $True 1225 | } Else { 1226 | $Command = $False 1227 | } 1228 | } Else { 1229 | $Command = $False 1230 | } 1231 | 1232 | $Connections += New-Object -TypeName PSObject -Property @{ 1233 | Name="Exchange" 1234 | Connected=$Connect 1235 | ConnectErrors=$ConnectError 1236 | TestCommand=$Command 1237 | TestCommandErrors=$CommandError 1238 | } 1239 | } 1240 | 1241 | <# 1242 | SharePoint 1243 | 1244 | #> 1245 | If($Bypass -notcontains "SPO") { 1246 | Import-PSModule -ModuleName Microsoft.Online.SharePoint.PowerShell -Implicit:$UseImplicitLoading 1247 | # Reset vars 1248 | $Connect = $False; $ConnectError = $Null; $Command = $False; $CommandError = $Null 1249 | 1250 | # Connect only if SPO admin domain provided or if not provided but Graph SDK is connected 1251 | if ($SPOAdminDomain -or (-not $SPOAdminDomain -and $GraphSDKConnected -eq $true)) { 1252 | $adminUrl = Get-SharePointAdminUrl -CloudEnvironment $CloudEnvironment 1253 | Write-Host "$(Get-Date) Connecting to SharePoint Online (using $adminUrl)..." 1254 | # Using the Credential parameter with a username will prompt for Basic auth creds 1255 | switch ($CloudEnvironment) { 1256 | "Commercial" {Connect-SPOService -Url $adminUrl -ErrorAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1257 | "USGovGCC" {Connect-SPOService -Url $adminUrl -ErrorAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1258 | "USGovGCCHigh" {Connect-SPOService -Url $adminUrl -Region ITAR -ErrorAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1259 | "USGovDoD" {Connect-SPOService -Url $adminUrl -Region ITAR -ErrorAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1260 | "China" {Connect-SPOService -Url $adminUrl -Region China -ErrorAction SilentlyContinue -ErrorVariable ConnectError | Out-Null} 1261 | } 1262 | 1263 | # If no error, try test command 1264 | If($ConnectError) { $Connect = $False; $Command = $False} Else { 1265 | $Connect = $True 1266 | # Cmdlet that can be run by anyone 1267 | Get-SPOSite -Limit 1 -ErrorAction SilentlyContinue -ErrorVariable CommandError -WarningAction SilentlyContinue | Out-Null 1268 | # Cmdlet that can be run by admin 1269 | #Get-SPOTenant -ErrorAction SilentlyContinue -ErrorVariable CommandError | Out-Null 1270 | If($CommandError) { $Command = $False } Else { $Command = $True } 1271 | } 1272 | 1273 | $Connections += New-Object -TypeName PSObject -Property @{ 1274 | Name="SPO" 1275 | Connected=$Connect 1276 | ConnectErrors=$ConnectError 1277 | TestCommand=$Command 1278 | TestCommandErrors=$CommandError 1279 | } 1280 | 1281 | } 1282 | 1283 | } 1284 | 1285 | <# 1286 | 1287 | Microsoft Teams 1288 | 1289 | #> 1290 | if ($Bypass -notcontains "Teams") { 1291 | # Connect to Teams only if a tenant SKU includes Teams service plan 1292 | if ($GraphSDKConnected -eq $true) { 1293 | $TeamsLicensed = (Get-LicenseStatus -LicenseType Teams) 1294 | } 1295 | if ($TeamsLicensed -eq $true) { 1296 | Import-PSModule -ModuleName MicrosoftTeams -Implicit:$UseImplicitLoading 1297 | # Reset vars 1298 | $Connect = $False; $ConnectError = $Null; $Command = $False; $CommandError = $Null 1299 | 1300 | Write-Host "$(Get-Date) Connecting to Microsoft Teams..." 1301 | # Although the connection cmdlet supports providing an account ID, if used it will force the user to [re-]authenticate rather than presenting the account picker 1302 | switch ($CloudEnvironment) { 1303 | "Commercial" {try {Connect-MicrosoftTeams} catch {New-Variable -Name ConnectError -Value $true}} 1304 | "USGovGCC" {try {Connect-MicrosoftTeams} catch {New-Variable -Name ConnectError -Value $true}} 1305 | "USGovGCCHigh" {try {Connect-MicrosoftTeams -TeamsEnvironmentName TeamsGCCH } catch {New-Variable -Name ConnectError -Value $true}} 1306 | "USGovDoD" {try {Connect-MicrosoftTeams -TeamsEnvironmentName TeamsDOD } catch {New-Variable -Name ConnectError -Value $true}} 1307 | "China" {try {Connect-MicrosoftTeams -TeamsEnvironmentName TeamsChina } catch {New-Variable -Name ConnectError -Value $true}} 1308 | } 1309 | 1310 | # If no error, try test command 1311 | if ($ConnectError) { 1312 | $Connect = $False 1313 | $Command = $False 1314 | } 1315 | else { 1316 | $Connect = $true 1317 | # Cmdlet that can be run by anyone 1318 | if (Get-CsOnlineUser -ResultSize 1) { 1319 | # Cmdlet that can be run by admin 1320 | #if (Get-CsTenantFederationConfiguration) { 1321 | $Command = $True 1322 | } 1323 | else { 1324 | $Command = $False 1325 | } 1326 | } 1327 | 1328 | $Connections += New-Object -TypeName PSObject -Property @{ 1329 | Name="Teams" 1330 | Connected=$Connect 1331 | ConnectErrors=$ConnectError 1332 | TestCommand=$Command 1333 | TestCommandErrors=$CommandError 1334 | } 1335 | } 1336 | } 1337 | 1338 | <# 1339 | 1340 | Power Apps 1341 | 1342 | #> 1343 | If($Bypass -notcontains 'PP') { 1344 | 1345 | Import-PSModule -ModuleName Microsoft.PowerApps.Administration.PowerShell -Implicit:$UseImplicitLoading 1346 | # Reset vars 1347 | $Connect = $False; $ConnectError = $Null; $Command = $False; $CommandError = $Null 1348 | 1349 | Write-Host "$(Get-Date) Connecting to Power Apps..." 1350 | if ($NoAdminUPN) { 1351 | switch ($CloudEnvironment) { 1352 | "Commercial" {try{Add-PowerAppsAccount | Out-Null}catch{$ConnectError=$_}} 1353 | "USGovGCC" {try{Add-PowerAppsAccount -Endpoint usgov | Out-Null}catch{$ConnectError=$_}} 1354 | "USGovGCCHigh" {try{Add-PowerAppsAccount -Endpoint usgovhigh | Out-Null}catch{$ConnectError=$_}} 1355 | "USGovDoD" {try{Add-PowerAppsAccount -Endpoint dod | Out-Null}catch{$ConnectError=$_}} 1356 | "China" {try{Add-PowerAppsAccount -Endpoint china | Out-Null}catch{$ConnectError=$_}} 1357 | } 1358 | } else { 1359 | switch ($CloudEnvironment) { 1360 | "Commercial" {try{Add-PowerAppsAccount -UserName $AdminUPN | Out-Null}catch{$ConnectError=$_}} 1361 | "USGovGCC" {try{Add-PowerAppsAccount -Endpoint usgov -UserName $AdminUPN | Out-Null}catch{$ConnectError=$_}} 1362 | "USGovGCCHigh" {try{Add-PowerAppsAccount -Endpoint usgovhigh -UserName $AdminUPN | Out-Null}catch{$ConnectError=$_}} 1363 | "USGovDoD" {try{Add-PowerAppsAccount -Endpoint dod -UserName $AdminUPN | Out-Null}catch{$ConnectError=$_}} 1364 | "China" {try{Add-PowerAppsAccount -Endpoint china -UserName $AdminUPN | Out-Null}catch{$ConnectError=$_}} 1365 | } 1366 | } 1367 | 1368 | # If no error, try test command 1369 | if ($ConnectError) { $Connect = $False; $Command = ""} Else { 1370 | $Connect = $True 1371 | # Check if data is returned 1372 | # Ensure that the correct module is used as Get-DlpPolicy also exists within the Exchange module 1373 | $cmdResult = Microsoft.PowerApps.Administration.PowerShell\Get-DlpPolicy -ErrorAction SilentlyContinue -ErrorVariable CommandError 1374 | if ($CommandError -or -not $cmdResult) { 1375 | # Cmdlet may not return data if no PA license assigned or user has not been to PPAC before 1376 | Write-Warning -Message "No data was returned when running the test command. This can occur if the admin has never used the Power Platform Admin Center (PPAC). Please go to https://aka.ms/ppac and sign in as the Global administrator or Dynamics 365 administrator account you used to connect to Power Platform in PowerShell. Then return here to continue." 1377 | Read-Host -Prompt "Press Enter after you have navigated to PPAC and signed in with the adminstrator account used above to connect to Power Platform in PowerShell." 1378 | $cmdResult = Microsoft.PowerApps.Administration.PowerShell\Get-DlpPolicy -ErrorAction SilentlyContinue -ErrorVariable CommandError 1379 | if ($CommandError -or -not $cmdResult) { 1380 | $Command = $False 1381 | } 1382 | else { 1383 | $Command = $true 1384 | } 1385 | } 1386 | else { 1387 | $Command = $True 1388 | } 1389 | } 1390 | 1391 | $Connections += New-Object -TypeName PSObject -Property @{ 1392 | Name="PowerApps" 1393 | Connected=$Connect 1394 | ConnectErrors=$ConnectError 1395 | TestCommand=$Command 1396 | TestCommandErrors=$CommandError 1397 | } 1398 | } 1399 | 1400 | Return $Connections 1401 | } 1402 | 1403 | Function Get-RequiredAppPermissions { 1404 | param 1405 | ( 1406 | [string]$CloudEnvironment="Commercial", 1407 | $HasMDELicense, 1408 | $HasMDILicense, 1409 | $HasATPP2License 1410 | ) 1411 | 1412 | <# 1413 | This function returns the required application permissions for the Entra application 1414 | 1415 | Required Application Permissions 1416 | 1417 | ID, Name and Resource are required 1418 | - ID is the scope's unique GUID 1419 | - Name is used during the token check (to see we are actually getting these scopes assigned to us) 1420 | - Resource is the application ID for the API we are using, usually this is "00000003-0000-0000-c000-000000000000" which is for Graph 1421 | #> 1422 | 1423 | $AppRoles = @() 1424 | 1425 | # Microsoft Graph 1426 | if ($CloudEnvironment -ne "China") { 1427 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1428 | ID="6e472fd1-ad78-48da-a0f0-97ab2c6b769e" 1429 | Name="IdentityRiskEvent.Read.All" 1430 | Type='Role' 1431 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1432 | } 1433 | } 1434 | 1435 | switch ($CloudEnvironment) { 1436 | "China" {$GUID = "be6befbd-4448-4fb0-bda5-5dc989bd62c4";break} 1437 | default {$GUID = "dc377aa6-52d8-4e23-b271-2a7ae04cedf3"} 1438 | } 1439 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1440 | ID=$GUID 1441 | Name="DeviceManagementConfiguration.Read.All" 1442 | Type='Role' 1443 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1444 | } 1445 | 1446 | switch ($CloudEnvironment) { 1447 | "China" {$GUID = "c11814fe-adc9-435b-8b25-9e186dcf7606";break} 1448 | default {$GUID = "b0afded3-3588-46d8-8b3d-9842eff778da"} 1449 | } 1450 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1451 | ID=$GUID 1452 | Name="AuditLog.Read.All" 1453 | Type='Role' 1454 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1455 | } 1456 | 1457 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1458 | ID="7ab1d382-f21e-4acd-a863-ba3e13f7da61" 1459 | Name="Directory.Read.All" 1460 | Type='Role' 1461 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1462 | } 1463 | 1464 | switch ($CloudEnvironment) { 1465 | "China" {$GUID = "9950d8b9-ffec-4dd5-9c9e-19542b393956";break} 1466 | default {$GUID = "246dd0d5-5bd0-4def-940b-0421030a5b68"} 1467 | } 1468 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1469 | ID=$GUID 1470 | Name="Policy.Read.All" 1471 | Type='Role' 1472 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1473 | } 1474 | 1475 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1476 | ID="78ce3f0f-a1ce-49c2-8cde-64b5c0896db4" 1477 | Name="user_impersonation" 1478 | Type='Scope' 1479 | Resource="00000007-0000-0000-c000-000000000000" # Dynamics 365 1480 | } 1481 | 1482 | switch ($CloudEnvironment) { 1483 | "China" {$GUID = "47d70536-eeb5-4b19-b059-f44e0f475f33";break} 1484 | default {$GUID = "c7fbd983-d9aa-4fa7-84b8-17382c103bc4"} 1485 | } 1486 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1487 | ID=$GUID 1488 | Name="RoleManagement.Read.All" 1489 | Type='Role' 1490 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1491 | } 1492 | 1493 | switch ($CloudEnvironment) { 1494 | "China" {$GUID = "6f135ef2-d208-48f4-b390-7893518e6950";break} 1495 | default {$GUID = "01e37dc9-c035-40bd-b438-b2879c4870a6"} 1496 | } 1497 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1498 | ID=$GUID 1499 | Name="PrivilegedAccess.Read.AzureADGroup" 1500 | Type='Role' 1501 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1502 | } 1503 | 1504 | switch ($CloudEnvironment) { 1505 | "China" {$GUID = "d90f9f4f-4a37-4b18-8d8b-d774cd8fd2d1";break} 1506 | default {$GUID = "18a4783c-866b-4cc7-a460-3d5e5662c884"} 1507 | } 1508 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1509 | ID=$GUID 1510 | Name="Application.ReadWrite.OwnedBy" 1511 | Type='Role' 1512 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1513 | } 1514 | 1515 | switch ($CloudEnvironment) { 1516 | "USGovGCCHigh" {$GUID = "47c980b8-449c-4b30-99e6-aeb22a11a023"} 1517 | "USGovDoD" {$GUID = "47c980b8-449c-4b30-99e6-aeb22a11a023"} 1518 | "China" {$GUID = "4cd4e808-f9db-48e3-9455-51ed99ea5ebe"} 1519 | default {$GUID = "bb70e231-92dc-4729-aff5-697b3f04be95"} 1520 | } 1521 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1522 | ID=$GUID 1523 | Name="OnPremDirectorySynchronization.Read.All" 1524 | Type='Role' 1525 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1526 | } 1527 | 1528 | switch ($CloudEnvironment) { 1529 | "China" {$AlertsAvailable=$false} 1530 | default {$AlertsAvailable=$true} 1531 | } 1532 | if ($AlertsAvailable -eq $true) { 1533 | Write-Verbose "Role for Alerts will be included in app" 1534 | switch ($CloudEnvironment) { 1535 | "USGovGCCHigh" {$GUID = "64c33fcb-e6aa-490d-bed5-6016a9ef8f6d"} 1536 | "USGovDoD" {$GUID = "64c33fcb-e6aa-490d-bed5-6016a9ef8f6d"} 1537 | default {$GUID = "472e4a4d-bb4a-4026-98d1-0b0d74cb74a5"} 1538 | } 1539 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1540 | ID=$GUID 1541 | Name="SecurityAlert.Read.All" 1542 | Type='Role' 1543 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1544 | } 1545 | } 1546 | 1547 | $MDEAvailable = $false 1548 | switch ($CloudEnvironment) { 1549 | "Commercial" {$MDEAvailable=$true;$THId="dd98c7f5-2d42-42d3-a0e4-633161547251";break} 1550 | "USGovGCC" {$MDEAvailable=$true;$THId="dd98c7f5-2d42-42d3-a0e4-633161547251";break} 1551 | "USGovGCCHigh" {$MDEAvailable=$true;$THId="5f804853-e3b1-447b-9a8b-6d3e1257c72a";break} 1552 | "USGovDoD" {$MDEAvailable=$true;$THId="5f804853-e3b1-447b-9a8b-6d3e1257c72a";break} 1553 | "China" {$MDEAvailable=$false;$THId="dd98c7f5-2d42-42d3-a0e4-633161547251"} 1554 | } 1555 | if (($HasMDELicense -eq $true -and $MDEAvailable -eq $true) -or $HasATPP2License -eq $true) { 1556 | Write-Verbose "Role for Advanced Hunting will be included in app" 1557 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1558 | ID=$THId 1559 | Name="ThreatHunting.Read.All" 1560 | Type='Role' 1561 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1562 | } 1563 | } 1564 | 1565 | $MDIAvailable = $false 1566 | switch ($CloudEnvironment) { 1567 | "Commercial" {$MDIAvailable=$true;break} 1568 | "USGovGCC" {$MDIAvailable=$true;break} 1569 | "USGovGCCHigh" {$MDIAvailable=$true;break} 1570 | "USGovDoD" {$MDIAvailable=$true;break} 1571 | "China" {$MDIAvailable=$false} 1572 | } 1573 | if ($HasMDILicense -eq $true -and $MDIAvailable -eq $true) { 1574 | Write-Verbose "Roles for Defender for Identity will be included in app" 1575 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1576 | ID="f8dcd971-5d83-4e1e-aa95-ef44611ad351" 1577 | Name="SecurityIdentitiesHealth.Read.All" 1578 | Type='Role' 1579 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1580 | } 1581 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1582 | ID="bf394140-e372-4bf9-a898-299cfc7564e5" 1583 | Name="SecurityEvents.Read.All" 1584 | Type='Role' 1585 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1586 | } 1587 | $AppRoles += New-Object -TypeName PSObject -Property @{ 1588 | ID="5f0ffea2-f474-4cf2-9834-61cda2bcea5c" 1589 | Name="SecurityIdentitiesSensors.Read.All" 1590 | Type='Role' 1591 | Resource="00000003-0000-0000-c000-000000000000" # Graph 1592 | } 1593 | } 1594 | 1595 | Return $AppRoles 1596 | } 1597 | 1598 | Function Invoke-ManualModuleCheck 1599 | { 1600 | <# 1601 | 1602 | Manual installation check 1603 | 1604 | Manual installs can cause issues with modules installed from the PowerShell gallery. 1605 | It is also difficult to update manual PowerShell module installs. 1606 | 1607 | #> 1608 | 1609 | Write-Host "$(Get-Date) Checking manual module installations..." 1610 | $ManualInstalls = Get-ManualModules 1611 | 1612 | If($ManualInstalls -gt 0) 1613 | { 1614 | 1615 | Write-Host "$(Get-Date) Modules manually installed that need to be removed:" 1616 | $ManualInstalls 1617 | 1618 | If($DoNotRemediate -eq $false){ 1619 | # Fix manual installs 1620 | $ManualInstalls = Get-ManualModules -Remediate 1621 | } 1622 | else { 1623 | $ManualInstalls = Get-ManualModules 1624 | } 1625 | 1626 | If($ManualInstalls.Count -gt 0) 1627 | { 1628 | Write-Important 1629 | 1630 | Write-Host "$(Get-Date) The module check has failed because some modules have been manually installed. These will conflict with newer required modules from the PowerShell Gallery." -ForegroundColor Red 1631 | 1632 | if ($DoNotRemediate -eq $false) { 1633 | Exit-Script 1634 | throw "$(Get-Date) An attempt to remove these from the PowerShell path was unsuccessful. You must remove them using Add/Remove Programs." 1635 | } 1636 | } 1637 | } 1638 | } 1639 | 1640 | Function Invoke-SOAVersionCheck 1641 | { 1642 | <# 1643 | 1644 | Determines if SOA module is up to date 1645 | 1646 | #> 1647 | 1648 | $SOAGallery = Find-Module SOA 1649 | $SOAModule = Get-Module SOA 1650 | 1651 | If($SOAGallery.Version -gt $SOAModule.Version) 1652 | { 1653 | $NewerAvailable = $True 1654 | } 1655 | else 1656 | { 1657 | $NewerAvailable = $False 1658 | } 1659 | 1660 | Write-Verbose "$(Get-Date) Invoke-SOAVersionCheck NewerAvailable $NewerAvailable Gallery $($SOAGallery.Version) Module $($SOAModule.Version)" 1661 | 1662 | Return New-Object -TypeName PSObject -Property @{ 1663 | NewerAvailable = $NewerAvailable 1664 | Gallery = $SOAGallery.Version 1665 | Module = $SOAModule.Version 1666 | } 1667 | 1668 | } 1669 | 1670 | function Get-SOAEntraApp { 1671 | Param( 1672 | [string]$CloudEnvironment 1673 | ) 1674 | 1675 | # Determine if Microsoft Entra application exists 1676 | # Retrieving the Count is mandatory when using Eventual consistency level, otherwise a HTTP/400 error is returned 1677 | $EntraApp = (Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications?`$filter=web/redirectUris/any(p:p eq 'https://security.optimization.assessment.local')&`$count=true" -Headers @{'ConsistencyLevel' = 'eventual'} -OutputType PSObject).Value 1678 | 1679 | if ($EntraApp -and $RemoveExistingEntraApp -and $DoNotRemediate -eq $false) { 1680 | Write-Host "$(Get-Date) Deleting existing Microsoft Entra application..." 1681 | try { 1682 | Invoke-MgGraphRequest -Method DELETE -Uri "$GraphHost/v1.0/applications/$($EntraApp.Id)" 1683 | $EntraApp = $null 1684 | } 1685 | catch { 1686 | Write-Warning "$(Get-Date) Unable to delete existing Microsoft Entra app registration. Please remove it manually." 1687 | } 1688 | } 1689 | 1690 | if (!$EntraApp) { 1691 | if ($DoNotRemediate -eq $false) { 1692 | Write-Host "$(Get-Date) Creating Microsoft Entra app registration..." 1693 | $EntraApp = Install-EntraApp -CloudEnvironment $CloudEnvironment 1694 | Write-Verbose "$(Get-Date) Get-SOAEntraApp App $($EntraApp.Id)" 1695 | } 1696 | } 1697 | else { 1698 | # Check whether the application name should be updated 1699 | if ($EntraApp.displayName -ne 'Microsoft 365 Security Assessment') { 1700 | Write-Verbose "$(Get-Date) Renaming the display name of the Microsoft Entra application..." 1701 | $Body = @{'displayName' = 'Microsoft 365 Security Assessment'} 1702 | Invoke-MgGraphRequest -Method PATCH -Uri "$GraphHost/v1.0/applications/$($EntraApp.Id)" -Body $Body 1703 | } 1704 | 1705 | # Check if public client URI is set 1706 | $pcRUrl = @('https://login.microsoftonline.com/common/oauth2/nativeclient') 1707 | if ($EntraApp.PublicClient.RedirectUris -notcontains $pcRUrl) { 1708 | if ($DoNotRemediate -eq $false){ 1709 | # Set as public client to be able to collect from Dynamics with delegated scope 1710 | Write-Verbose "$(Get-Date) Setting Microsoft Entra application public client redirect URI..." 1711 | $Params = @{ 1712 | 'publicClient' = @{ 1713 | 'redirectUris' = $pcRUrl 1714 | } 1715 | } 1716 | Invoke-MgGraphRequest -Method PATCH -Uri "$GraphHost/v1.0/applications/$($EntraApp.Id)" -Body $Params 1717 | 1718 | # Get app again so public client is set for checking DoNotRemediate in calling function 1719 | $EntraApp = (Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications?`$filter=web/redirectUris/any(p:p eq 'https://security.optimization.assessment.local')&`$count=true" -Headers @{'ConsistencyLevel' = 'eventual'} -OutputType PSObject).Value 1720 | } 1721 | } 1722 | # Check if correct web redirect URIs are set 1723 | $webRUri = @("https://security.optimization.assessment.local","https://o365soa.github.io/soa/") 1724 | if (Compare-Object -ReferenceObject $EntraApp.Web.RedirectUris -DifferenceObject $webRUri) { 1725 | if ($DoNotRemediate -eq $false) { 1726 | Write-Verbose "$(Get-Date) Setting Microsoft Entra application web redirect URIs..." 1727 | $Params = @{ 1728 | 'web' = @{ 1729 | 'redirectUris' = $webRUri 1730 | } 1731 | } 1732 | Invoke-MgGraphRequest PATCH "$GraphHost/v1.0/applications/$($EntraApp.Id)" -Body $Params 1733 | 1734 | $EntraApp = (Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications?`$filter=web/redirectUris/any(p:p eq 'https://security.optimization.assessment.local')&`$count=true" -Headers @{'ConsistencyLevel' = 'eventual'} -OutputType PSObject).Value 1735 | } 1736 | } 1737 | # Check if service principal (enterprise app) is owner of its app registration 1738 | $appOwners = (Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications/$($EntraApp.Id)/owners" -OutputType PSObject).Value 1739 | $appSp = Get-SOAAppServicePrincipal -EntraApp $EntraApp 1740 | if ($appSp) { 1741 | if ($appOwners.Id -notcontains $appSp.Id) { 1742 | if ($DoNotRemediate -eq $false) { 1743 | if (Add-SOAAppOwner -NewOwnerObjectId $appSp.Id -EntraApp $EntraApp) { 1744 | $script:appSelfOwner = $true 1745 | } else { 1746 | $script:appSelfOwner = $false 1747 | } 1748 | } 1749 | } else { 1750 | $script:appSelfOwner = $true 1751 | } 1752 | } else { 1753 | $script:appSelfOwner = $false 1754 | } 1755 | } 1756 | 1757 | Return $EntraApp 1758 | 1759 | } 1760 | 1761 | function Get-SOAAppServicePrincipal { 1762 | param ( 1763 | $EntraApp 1764 | ) 1765 | $connCount = 0 1766 | $connLimit = 5 1767 | do { 1768 | try { 1769 | $connCount++ 1770 | Write-Verbose "$(Get-Date) Get-SOAAppServicePrincipal: Getting app service principal attempt #$connCount" 1771 | $sp = Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/servicePrincipals(appId=`'$($EntraApp.AppId)`')" -OutputType PSObject 1772 | return $sp 1773 | } catch { 1774 | Write-Verbose $_.Exception.Message 1775 | Start-Sleep -Seconds 2 1776 | } 1777 | } until ($connCount -eq $connLimit) 1778 | } 1779 | 1780 | function Add-SOAAppOwner { 1781 | param ( 1782 | $NewOwnerObjectId, 1783 | $EntraApp 1784 | ) 1785 | $params = @{ 1786 | '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$NewOwnerObjectId" 1787 | } 1788 | Write-Verbose "$(Get-Date) Adding Microsoft Entra application as owner of its app registration..." 1789 | Invoke-MgGraphRequest -Method POST -Uri "$GraphHost/v1.0/applications(appId=`'$($EntraApp.AppId)`')/owners/`$ref" -Body $params 1790 | if ($?) {return $true} else {return $false} 1791 | } 1792 | 1793 | Function Test-SOAApplication 1794 | { 1795 | Param 1796 | ( 1797 | [Parameter(Mandatory=$true)] 1798 | $App, 1799 | $Secret, 1800 | $TenantDomain, 1801 | [Switch]$WriteHost, 1802 | [Switch]$ManualCred, 1803 | [Alias("O365EnvironmentName")][string]$CloudEnvironment="Commercial" 1804 | ) 1805 | 1806 | Write-Verbose "$(Get-Date) Test-SOAApplication App $($App.AppId) TenantDomain $($TenantDomain) SecretLength $($Secret.Length) CloudEnvironment $CloudEnvironment" 1807 | 1808 | # Perform permission check, except when manually providing the secret because there will be no delegated connection 1809 | if ($ManualCred -eq $False) { 1810 | If($WriteHost) { Write-Host "$(Get-Date) Performing application permission check... (This may take up to 5 minutes)" } 1811 | $PermCheck = Invoke-AppPermissionCheck -App $App 1812 | } 1813 | 1814 | # Perform check for consent 1815 | if ($PermCheck -eq $True) { 1816 | If ($WriteHost) { Write-Host "$(Get-Date) Performing token check... (This may take up to 5 minutes)" } 1817 | $TokenCheck = Invoke-AppTokenRolesCheckV2 -CloudEnvironment $CloudEnvironment 1818 | } else { 1819 | # Set as False to ensure the final result shows the check as Failed instead of Null 1820 | $TokenCheck = $False 1821 | } 1822 | 1823 | # Get total user count 1824 | if ($TokenCheck -eq $true) { 1825 | $headers = @{ 1826 | consistencyLevel = 'eventual' 1827 | } 1828 | # Returns the first page of results along with the total count that will be returned if all pages are requested 1829 | $countResponse = Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/users?`$count=true&`$select=id" -headers $headers -OutputType PSObject 1830 | if ($countResponse."@odata.count" -gt 400000) { 1831 | $countNote = 'Recommended to run user-level collection phases separately' 1832 | } elseif ($countResponse."@odata.count" -gt 200000) { 1833 | $countNote = 'Consider running user-level collection phases separately' 1834 | } 1835 | } 1836 | 1837 | Return New-Object -TypeName PSObject -Property @{ 1838 | Permissions=$PermCheck 1839 | Token=$TokenCheck 1840 | UserCount=$countResponse."@odata.count" 1841 | CountNote=$countNote 1842 | } 1843 | 1844 | } 1845 | 1846 | Function Install-SOAPrerequisites { 1847 | [CmdletBinding(DefaultParametersetname="Default")] 1848 | Param ( 1849 | [Parameter(ParameterSetName='Default')] 1850 | [Parameter(ParameterSetName='ConnectOnly')] 1851 | [Parameter(ParameterSetName='ModulesOnly')] 1852 | [ValidateSet("EXO","SCC","SPO","PP","Teams","Graph","ActiveDirectory")][string[]]$Bypass, 1853 | [switch]$UseProxy, 1854 | [Parameter(DontShow)][Switch]$AllowMultipleWindows, 1855 | [Parameter(DontShow)][switch]$NoVersionCheck, 1856 | [switch]$RemoveMultipleModuleVersions, 1857 | [switch]$UseImplicitLoading, 1858 | [Parameter(ParameterSetName='Default')] 1859 | [Parameter(ParameterSetName='ConnectOnly')] 1860 | [ValidateScript({if (Resolve-DnsName -Name $PSItem) {$true} else {throw "SPO admin domain does not resolve. Verify you entered a valid fully qualified domain name."}})] 1861 | [ValidateNotNullOrEmpty()][string]$SPOAdminDomain, 1862 | [Parameter(ParameterSetName='Default')] 1863 | [Parameter(ParameterSetName='ModulesOnly')] 1864 | [Parameter(ParameterSetName='EntraAppOnly')] 1865 | [switch]$DoNotRemediate, 1866 | [Parameter(ParameterSetName='Default')] 1867 | [Parameter(ParameterSetName='ConnectOnly')] 1868 | [Parameter(ParameterSetName='EntraAppOnly')] 1869 | [Parameter(ParameterSetName='ModulesOnly')] 1870 | [Alias('O365EnvironmentName')][ValidateSet("Commercial","USGovGCC","USGovGCCHigh","USGovDoD","China")][string]$CloudEnvironment, 1871 | [Parameter(ParameterSetName='ConnectOnly')] 1872 | [switch]$ConnectOnly, 1873 | [Parameter(ParameterSetName='ModulesOnly')] 1874 | [switch]$ModulesOnly, 1875 | [Parameter(ParameterSetName='Default')] 1876 | [Parameter(ParameterSetName='ModulesOnly')] 1877 | [switch]$SkipADModule, 1878 | [Parameter(ParameterSetName='Default')] 1879 | [Parameter(ParameterSetName='ModulesOnly')] 1880 | [switch]$ADModuleOnly, 1881 | [Parameter(ParameterSetName='EntraAppOnly')] 1882 | [Alias('AzureADAppOnly')][switch]$EntraAppOnly, 1883 | [Parameter(ParameterSetName='Default')] 1884 | [Parameter(ParameterSetName='EntraAppOnly')] 1885 | [switch]$RemoveExistingEntraApp, 1886 | [Parameter(ParameterSetName='Default')] 1887 | [Parameter(ParameterSetName='EntraAppOnly')] 1888 | [switch]$PromptForApplicationSecret, 1889 | [Parameter(ParameterSetName='Default')] 1890 | [Parameter(ParameterSetName='EntraAppOnly')] 1891 | [switch]$HasEntraP1License, 1892 | [Parameter(ParameterSetName='Default')] 1893 | [Parameter(ParameterSetName='EntraAppOnly')] 1894 | [switch]$HasEntraP2License, 1895 | [Parameter(ParameterSetName='Default')] 1896 | [Parameter(ParameterSetName='EntraAppOnly')] 1897 | [switch]$HasMDOP2License, 1898 | [Parameter(ParameterSetName='Default')] 1899 | [Parameter(ParameterSetName='EntraAppOnly')] 1900 | [switch]$HasMDELicense, 1901 | [Parameter(ParameterSetName='Default')] 1902 | [Parameter(ParameterSetName='EntraAppOnly')] 1903 | [switch]$HasMDILicense, 1904 | [Parameter(ParameterSetName='Default')] 1905 | [Parameter(ParameterSetName='ModulesOnly')] 1906 | [switch]$HasTeamsLicense, 1907 | [Parameter(ParameterSetName='Default')] 1908 | [Parameter(ParameterSetName='EntraAppOnly')] 1909 | $GraphClientId, 1910 | [Parameter(ParameterSetName='Default')] 1911 | [Parameter(ParameterSetName='EntraAppOnly')] 1912 | [ValidateScript({if ($PSItem -match "^\w+.onmicrosoft.(com|us)`$|^\w+.partner.onmschina.cn`$") {$true} else {throw "The value `"$PSItem`" is not a properly formatted initial domain."}})] 1913 | $InitialDomain, 1914 | [ValidateScript({if ($PSItem -match "^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*`$") {$true} else {throw "The value `"$PSItem`" is not a properly formatted UPN."}})] 1915 | [string]$AdminUPN, 1916 | [switch]$NoAdminUPN 1917 | ) 1918 | 1919 | <# 1920 | 1921 | Variable setting 1922 | 1923 | #> 1924 | 1925 | # Detect if running in ISE and abort ($psise is an automatic variable that exists only in the ISE) 1926 | if ($psise) 1927 | { 1928 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 1929 | } 1930 | 1931 | # Detect if running in PS 7 1932 | # Teams supports 7.2, EXO supports 7.0.3, Graph supports 7.0, PP and SPO do not natively support 7 (but generally work when using -UseWindowsPowerShell) 1933 | if ($PSVersionTable.PSVersion.ToString() -like "7.*") { 1934 | throw "Running this script in PowerShell 7 is not supported." 1935 | } 1936 | 1937 | # Default run 1938 | $ConnectCheck = $True 1939 | $ModuleCheck = $True 1940 | $EntraAppCheck = $True 1941 | 1942 | # Default to remediate (applicable only when not using ConnectOnly) 1943 | if ($DoNotRemediate -eq $false -and $PromptForApplicationSecret -eq $false){ 1944 | $Remediate = $true 1945 | } 1946 | else { 1947 | $Remediate = $false 1948 | } 1949 | 1950 | 1951 | # Change based on ModuleOnly flag 1952 | If($ModulesOnly) { 1953 | $ConnectCheck = $False 1954 | $ModuleCheck = $True 1955 | $EntraAppCheck = $False 1956 | } 1957 | 1958 | # Change based on ConnectOnly flag 1959 | If($ConnectOnly) { 1960 | $ConnectCheck = $True 1961 | $EntraAppCheck = $False 1962 | $ModuleCheck = $False 1963 | } 1964 | 1965 | # Change based on EntraAppOnly flag 1966 | If($EntraAppOnly) { 1967 | $ConnectCheck = $False 1968 | $EntraAppCheck = $True 1969 | $ModuleCheck = $False 1970 | } 1971 | 1972 | # Change based on SkipADModule flag 1973 | If($SkipADModule) { 1974 | $Bypass+="ActiveDirectory" 1975 | } 1976 | 1977 | <# 1978 | 1979 | Directory creating and transcript starting 1980 | 1981 | #> 1982 | $SOADirectory = Get-SOADirectory 1983 | $TranscriptName = "prereq-$(Get-Date -Format "MMddyyyyHHmms")-log.txt" 1984 | Start-Transcript "$SOADirectory\$TranscriptName" 1985 | 1986 | if ($DoNotRemediate){ 1987 | Write-Host "$(Get-Date) The DoNotRemediate switch was used. Any missing or outdated modules, as well as the registration and/or configuration of the Microsoft Entra enterprise application will not be performed." -ForegroundColor Yellow 1988 | } 1989 | 1990 | if ($NoVersionCheck) { 1991 | Write-Host "$(Get-Date) NoVersionCheck switch was used. Skipping version check of the SOA module." 1992 | } 1993 | else { 1994 | # Check for newer version 1995 | Write-Host "$(Get-Date) Checking if the latest version of the SOA module is installed..." 1996 | $VersionCheck = Invoke-SOAVersionCheck 1997 | If($VersionCheck.NewerAvailable -eq $true) 1998 | { 1999 | Exit-Script 2000 | throw "Version $($VersionCheck.Gallery) of the SOA module has been released. Your version $($VersionCheck.Module) is out of date. Run Update-Module SOA." 2001 | 2002 | } 2003 | } 2004 | 2005 | # Require local admin and single PowerShell window if multiple modules will be removed 2006 | if ($RemoveMultipleModuleVersions) { 2007 | If($(Get-IsAdministrator) -eq $False -and $ModuleCheck -eq $True -and $DoNotRemediate -eq $false) { 2008 | Exit-Script 2009 | throw "PowerShell must be run as an administrator to be able to uninstall multiple versions of modules." 2010 | 2011 | } 2012 | If($AllowMultipleWindows) { 2013 | Write-Important 2014 | Write-Host "Allow multiple windows has been specified. This should not be used in general operation. Module remediation may fail!" 2015 | } 2016 | Else 2017 | { 2018 | If($(Get-PowerShellCount) -gt 1 -and $ModuleCheck -eq $True -and $DoNotRemediate -eq $false) { 2019 | Exit-Script 2020 | throw "There are multiple PowerShell windows open. This can cause issues with PowerShell modules being uninstalled. Close all open PowerShell windows and try again." 2021 | 2022 | } 2023 | } 2024 | } 2025 | 2026 | # Check that only the AD module is installed on a standalone machine, and then exit the script 2027 | If($ADModuleOnly) { 2028 | Write-Host "$(Get-Date) ADModuleOnly switch was used. The on-premises AD module will be installed and then the script will exit." 2029 | 2030 | $ModuleCheckResult = @(Get-ModuleStatus -ModuleName "ActiveDirectory") 2031 | $ModuleCheckResult | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,Multiple,NewerAvailable 2032 | 2033 | If($null -ne $ModuleCheckResult.InstalledVersion) { 2034 | Write-Host "$(Get-Date) ActiveDirectory module is already installed" 2035 | } 2036 | Else { 2037 | If($Remediate) { 2038 | Write-Host "$(Get-Date) Installing AD module" 2039 | Install-ADDSModule 2040 | } 2041 | 2042 | Write-Host "$(Get-Date) Post-remediation module check..." 2043 | $ModuleCheckResult = @(Get-ModuleStatus -ModuleName "ActiveDirectory") 2044 | $ModuleCheckResult | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,Multiple,NewerAvailable 2045 | } 2046 | 2047 | Stop-Transcript 2048 | break 2049 | } 2050 | 2051 | # Final check list 2052 | $CheckResults = @() 2053 | 2054 | <# 2055 | 2056 | Display the banner 2057 | 2058 | #> 2059 | Write-Host "" 2060 | Write-Host "This script is used to install and validate the prerequisites for running the data collection" 2061 | Write-Host "for one of the Microsoft 365 security assessments offered via Microsoft Services." 2062 | Write-Host "At the conclusion of this script running successfully, a file named SOA-PreCheck.json will be created." 2063 | Write-Host "This file should be sent to the engineer who will be delivering the assessment." 2064 | Write-Host "" 2065 | Write-Host "This script MUST be run on the workstation that will be used to perform the data collection for the assessment." 2066 | Write-Host "" 2067 | 2068 | if ($DoNotRemediate -eq $false -and $ConnectOnly -eq $false) { 2069 | Write-Important 2070 | Write-Host "This script makes changes on this machine and in your Microsoft 365 tenant. Per the parameters used, the following will occur:" -ForegroundColor Green 2071 | if ($ModuleCheck) { 2072 | Write-Host "- Install the latest version of PowerShell modules on this machine that are required for the assessment" -ForegroundColor Green 2073 | } 2074 | if ($EntraAppCheck) { 2075 | Write-Host "- Create a Microsoft Entra enterprise application in your tenant:" -ForegroundColor Green 2076 | Write-Host " -- The application name is 'Microsoft Security Assessment'" -ForegroundColor Green 2077 | Write-Host " -- The application will not be visible to end users" -ForegroundColor Green 2078 | Write-Host " -- The application secret (password) will not be stored, is randomly generated, and is removed when the prerequisites installation is complete." -ForegroundColor Green 2079 | Write-Host " (The application will not work without a secret. Do NOT remove the application until the conclusion of the engagement.)" -ForegroundColor Green 2080 | } 2081 | Write-Host "" 2082 | 2083 | While($True) { 2084 | if ($EntraAppOnly) { 2085 | $rhInput = Read-Host "Do you agree with the changes above (y/n)" 2086 | } else { 2087 | $rhInput = Read-Host "Is this script being run on the machine that will be used to pertform the data collection, and do you agree with the changes above (y/n)" 2088 | } 2089 | if($rhInput -eq "n") { 2090 | Exit-Script 2091 | } elseif($rhInput -eq "y") { 2092 | Write-Host "" 2093 | break; 2094 | } 2095 | } 2096 | } 2097 | 2098 | <# 2099 | 2100 | Proxy requirement auto-detection 2101 | 2102 | #> 2103 | 2104 | if ($UseProxy) { 2105 | Write-Host "The UseProxy switch was used. An attempt will be made to connect through the proxy infrastructure where possible." 2106 | $RPSProxySetting = New-PSSessionOption -ProxyAccessType IEConfig 2107 | } else { 2108 | Write-Host "Proxy requirement was not specified with UseProxy. Connection will be attempted directly." 2109 | Write-Host "" 2110 | $RPSProxySetting = New-PSSessionOption -ProxyAccessType None 2111 | } 2112 | 2113 | # Download module file to determine if any versions should be skipped. Used by both the Module and Connection checks 2114 | try { 2115 | $moduleResponse = Invoke-WebRequest -Uri "https://o365soa.github.io/soa/moduleversion.json" -UseBasicParsing 2116 | } catch {} 2117 | 2118 | if ($moduleResponse.StatusCode -eq 200) { 2119 | $script:moduleVersions = $moduleResponse.Content | ConvertFrom-Json 2120 | } 2121 | 2122 | <# 2123 | 2124 | Perform the module check 2125 | 2126 | #> 2127 | 2128 | If($ModuleCheck -eq $True) { 2129 | 2130 | # Determine if the nuget provider is available 2131 | 2132 | If(!(Get-PackageProvider -Name nuget -ErrorAction:SilentlyContinue -WarningAction:SilentlyContinue)) { 2133 | Install-PackageProvider -Name NuGet -Force | Out-Null 2134 | } 2135 | 2136 | # Determine if PowerShell Gallery is configured as the default repository 2137 | If(!(Get-PSRepository -Name PSGallery -ErrorAction:SilentlyContinue -WarningAction:SilentlyContinue)) { 2138 | Register-PSRepository -Default -InstallationPolicy Trusted | Out-Null 2139 | } 2140 | 2141 | Invoke-ManualModuleCheck 2142 | 2143 | Write-Host "$(Get-Date) Checking modules..." 2144 | 2145 | $ModuleCheckResult = Invoke-SOAModuleCheck 2146 | 2147 | if ($RemoveMultipleModuleVersions) { 2148 | $Modules_OK = @($ModuleCheckResult | Where-Object {$_.Installed -eq $True -and $_.Multiple -eq $False -and $_.NewerAvailable -ne $true}) 2149 | $Modules_Error = @($ModuleCheckResult | Where-Object {$_.Installed -eq $False -or $_.Multiple -eq $True -or $_.NewerAvailable -eq $true -or $_.Conflict -eq $True}) 2150 | } 2151 | else { 2152 | $Modules_OK = @($ModuleCheckResult | Where-Object {$_.Installed -eq $True -and $_.NewerAvailable -ne $true}) 2153 | $Modules_Error = @($ModuleCheckResult | Where-Object {$_.Installed -eq $False -or $_.NewerAvailable -eq $true -or $_.Conflict -eq $True}) 2154 | } 2155 | 2156 | If($Modules_Error.Count -gt 0) { 2157 | Write-Host "$(Get-Date) Modules that require remediation:" -ForegroundColor Yellow 2158 | if ($RemoveMultipleModuleVersions) { 2159 | $Modules_Error | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,Multiple,NewerAvailable 2160 | } 2161 | else { 2162 | $Modules_Error | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,NewerAvailable 2163 | } 2164 | 2165 | # Fix modules with errors unless instructed not to 2166 | if ($DoNotRemediate -eq $false){ 2167 | Invoke-ModuleFix $Modules_Error 2168 | 2169 | Write-Host "$(Get-Date) Post-remediation module check..." 2170 | $ModuleCheckResult = Invoke-SOAModuleCheck -CloudEnvironment $CloudEnvironment 2171 | if ($RemoveMultipleModuleVersions) { 2172 | $Modules_OK = @($ModuleCheckResult | Where-Object {$_.Installed -eq $True -and $_.Multiple -eq $False -and $_.NewerAvailable -ne $true}) 2173 | $Modules_Error = @($ModuleCheckResult | Where-Object {$_.Installed -eq $False -or $_.Multiple -eq $True -or $_.NewerAvailable -eq $true}) 2174 | } 2175 | else { 2176 | $Modules_OK = @($ModuleCheckResult | Where-Object {$_.Installed -eq $True -and $_.NewerAvailable -ne $true}) 2177 | $Modules_Error = @($ModuleCheckResult | Where-Object {$_.Installed -eq $False -or $_.NewerAvailable -eq $true -or $_.Conflict -eq $True}) 2178 | } 2179 | } 2180 | else { 2181 | Write-Host "$(Get-Date) Skipping remediation tasks because DoNotRemediate was used." -ForegroundColor Yellow 2182 | } 2183 | 2184 | If($Modules_Error.Count -gt 0) { 2185 | Write-Host "$(Get-Date) The following modules have errors (a property value is True) that must be remediated:" -ForegroundColor Red 2186 | if ($RemoveMultipleModuleVersions) { 2187 | $Modules_Error | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,Multiple,NewerAvailable 2188 | } 2189 | else { 2190 | $Modules_Error | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,NewerAvailable 2191 | } 2192 | 2193 | if ($RemoveMultipleModuleVersions -and ($Modules_Error | Where-Object {$_.Multiple -eq $true})){ 2194 | Write-Host "Paths to modules with multiple versions:" 2195 | foreach ($m in ($Modules_Error | Where-Object {$_.Multiple -eq $true})) { 2196 | Write-Host "" 2197 | Write-Host "Module:" -NoNewline 2198 | $m | Select-Object -ExpandProperty Module 2199 | Write-Host "Path:" 2200 | $m | Select-Object -ExpandProperty Path 2201 | Write-Host "" 2202 | } 2203 | } 2204 | 2205 | # Don't continue to check connections 2206 | Exit-Script 2207 | throw "$(Get-Date) The above modules must be remediated before continuing. Contact the delivery engineer for assistance, if needed." 2208 | 2209 | } 2210 | } 2211 | } 2212 | 2213 | <# 2214 | 2215 | Perform the connection check 2216 | 2217 | #> 2218 | 2219 | If($ConnectCheck -eq $True) { 2220 | # Get the cloud environment if not provided 2221 | if (-not $CloudEnvironment) { 2222 | if (-not $AdminUPN) { 2223 | if ($NoAdminUPN) { 2224 | Write-Error -Message "When NoAdminUPN is used, the cloud environment must be provided using the CloudEnvironment parameter." 2225 | Exit-Script 2226 | } else { 2227 | # Get Admin UPN 2228 | do { 2229 | $AdminUPN = Read-Host -Prompt "Enter the UPN of the account that will be used to connect to Microsoft 365. (If providing a UPN is causing authentication issues, you can press Ctrl-C to abort the script and run it again with the NoAdminUPN and CloudEnvironment parameters.)" 2230 | } 2231 | until ($AdminUPN -match "^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$") 2232 | Write-Host "" 2233 | } 2234 | } 2235 | try { 2236 | $CloudEnvironment = Get-CloudEnvironment -UPN $AdminUPN 2237 | } catch { 2238 | Exit-Script 2239 | throw "There was an error determining the cloud environment for $UPN. Use the CloudEnvironment parameter to specify a cloud." 2240 | } 2241 | } 2242 | switch ($CloudEnvironment) { 2243 | "Commercial" {$GraphHost = "https://graph.microsoft.com"} 2244 | "USGovGCC" {$GraphHost = "https://graph.microsoft.com"} 2245 | "USGovGCCHigh" {$GraphHost = "https://graph.microsoft.us"} 2246 | "USGovDoD" {$GraphHost = "https://dod-graph.microsoft.us"} 2247 | "China" {$GraphHost = "https://microsoftgraph.chinacloudapi.cn"} 2248 | } 2249 | 2250 | # Proceed to testing connections 2251 | 2252 | $Connections = @(Test-Connections -RPSProxySetting $RPSProxySetting -CloudEnvironment $CloudEnvironment) 2253 | 2254 | $Connections_OK = @($Connections | Where-Object {$_.Connected -eq $True -and $_.TestCommand -eq $True}) 2255 | $Connections_Error = @($Connections | Where-Object {$_.Connected -eq $False -or $_.TestCommand -eq $False -or $Null -ne $_.OtherErrors}) 2256 | } 2257 | 2258 | If($EntraAppCheck -eq $True) { 2259 | # Check if the InitialDomain was not provided, which is required when skipping delegated connection entirely 2260 | if (($null -ne $GraphClientId -and $PromptForApplicationSecret -eq $true) -and $null -eq $InitialDomain) { 2261 | Exit-Script 2262 | throw "The GraphClientId and PromptForApplicationSecret parameters were used, but InitialDomain was not specified. Re-run the script with the InitialDomain parameter" 2263 | } 2264 | 2265 | # Get the cloud environment if not provided 2266 | if (-not $CloudEnvironment) { 2267 | if (-not $AdminUPN) { 2268 | if ($NoAdminUPN) { 2269 | Write-Error -Message "When NoAdminUPN is used, the cloud instance must be provided using the CloudEnvironment parameter." 2270 | Exit-Script 2271 | } else { 2272 | # Get Admin UPN 2273 | do { 2274 | $AdminUPN = Read-Host -Prompt "Enter the UPN of the account that will be used to connect to Microsoft 365. (If providing a UPN is causing authentication issues, you can press Ctrl-C to abort the script and run it again with the NoAdminUPN and CloudEnvironment parameters.)" 2275 | } 2276 | until ($AdminUPN -match "^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$") 2277 | Write-Host "" 2278 | } 2279 | } 2280 | try { 2281 | $CloudEnvironment = Get-CloudEnvironment -UPN $AdminUPN 2282 | } catch { 2283 | Exit-Script 2284 | throw "There was an error determining the cloud environment for $UPN. Use the CloudEnvironment parameter to specify a cloud instance." 2285 | } 2286 | } 2287 | 2288 | # When EntraAppOnly is used, this script may not be connected to Microsoft Graph 2289 | switch ($CloudEnvironment) { 2290 | "Commercial" {$cloud = 'Global'} 2291 | "USGovGCC" {$cloud = 'Global'} 2292 | "USGovGCCHigh" {$cloud = 'USGov'} 2293 | "USGovDoD" {$cloud = 'USGovDoD'} 2294 | "China" {$cloud = 'China'} 2295 | } 2296 | 2297 | $mgContext = (Get-MgContext).Scopes 2298 | # Skip delegated connection if providing GraphClientId and the App Secret manually, otherwise evaluate whether the correct scope was requested 2299 | if ($mgContext -notcontains 'Application.ReadWrite.All' -or ($mgContext -notcontains 'Organization.Read.All' -and $mgContext -notcontains 'Directory.Read.All') -and ($null -eq $GraphClientId -or $PromptForApplicationSecret -ne $true)) { 2300 | Write-Host "$(Get-Date) Connecting to Graph with delegated authentication..." 2301 | if ($null -ne (Get-MgContext)){Disconnect-MgGraph | Out-Null} 2302 | $connCount = 0 2303 | $connLimit = 5 2304 | do { 2305 | try { 2306 | $connCount++ 2307 | Write-Verbose "$(Get-Date) Install-SOAPrerequisites: Graph Delegated connection attempt #$connCount" 2308 | 2309 | if ($CloudEnvironment -eq "China") { 2310 | # Connections to 21Vianet must have manually provided the App ID and tenant name 2311 | if (-not $GraphClientId -or -not $InitialDomain) { 2312 | Exit-Script 2313 | throw "$(Get-Date) Connections to Graph in 21Vianet require the application ID (client ID) and tenant name (initial domain) be manually provided. Use both `-GraphClientId` and `-InitialDomain` parameters to provide them. For more information, see https://github.com/o365soa/soa." 2314 | } 2315 | 2316 | Connect-MgGraph -Scopes 'Application.ReadWrite.All','Organization.Read.All' -Environment $cloud -ContextScope "Process" -ClientId $GraphClientId -Tenant $InitialDomain | Out-Null 2317 | } elseif ($PromptForApplicationSecret) { 2318 | # Request read-only permissions to Graph if manually providing the client secret 2319 | Connect-MgGraph -Scopes 'Application.Read.All','Organization.Read.All' -Environment $cloud -ContextScope "Process" | Out-Null 2320 | } else { 2321 | Connect-MgGraph -Scopes 'Application.ReadWrite.All','Organization.Read.All' -Environment $cloud -ContextScope "Process" | Out-Null 2322 | } 2323 | } 2324 | catch { 2325 | Write-Verbose $_ 2326 | Start-Sleep 1 2327 | } 2328 | } 2329 | until ($null -ne (Get-MgContext) -or $connCount -eq $connLimit) 2330 | if ($null -eq (Get-MgContext)) { 2331 | Write-Error -Message "Unable to connect to Graph. Skipping Microsoft Entra application check." 2332 | } 2333 | } 2334 | 2335 | if (Get-MgContext) { 2336 | Write-Host "$(Get-Date) Checking Microsoft Entra enterprise application..." 2337 | 2338 | $script:MDELicensed = Get-LicenseStatus -LicenseType MDE 2339 | #Write-Verbose "$(Get-Date) Get-LicenseStatus MDE License found: $($script:MDELicensed)" 2340 | 2341 | $script:MDILicensed = Get-LicenseStatus -LicenseType MDI 2342 | #Write-Verbose "$(Get-Date) Get-LicenseStatus MDI License found: $($script:MDILicensed)" 2343 | 2344 | $script:ATPP2Licensed = Get-LicenseStatus -LicenseType ATPP2 2345 | #Write-Verbose "$(Get-Date) Get-LicenseStatus ATPP2 License found: $($script:ATPP2Licensed)" 2346 | 2347 | # Determine if Microsoft Entra application exists (and has public client redirect URI set) and create (or recreate) if it doesn't. 2348 | $EntraApp = Get-SOAEntraApp -CloudEnvironment $CloudEnvironment 2349 | } 2350 | 2351 | # EntraApp will have a value if connecting using Delegated. If skipping Delegated entirely, then the initial domain still needs to be queried 2352 | If($EntraApp -or ($GraphClientId -and $PromptForApplicationSecret)) { 2353 | # Get the tenant domain 2354 | $tenantdomain = Get-InitialDomain 2355 | 2356 | if ($PromptForApplicationSecret -eq $True) { 2357 | # Prompt for the client secret needed to connect to the application 2358 | $SSCred = $null 2359 | 2360 | Write-Host "$(Get-Date) At the prompt, provide a valid client secret for the assessment's app registration." 2361 | Start-Sleep -Seconds 1 2362 | while ($null -eq $SSCred -or $SSCred.Length -eq 0) { 2363 | # UserName is a required parameter for Get-Credential but it's value is not used elsewhere in the script 2364 | $SSCred = (Get-Credential -Message "Enter the app registration's client secret into the password field." -UserName "Microsoft Security Assessment").Password 2365 | Start-Sleep 1 # Add a delay to allow to aborting to console 2366 | } 2367 | } else { 2368 | # Reset secret 2369 | $clientsecret = Reset-SOAAppSecret -App $EntraApp -Task "Prereq" 2370 | $SSCred = $clientsecret | ConvertTo-SecureString -AsPlainText -Force 2371 | Write-Host "$(Get-Date) Sleeping to allow for replication of the app registration's new client secret..." 2372 | Start-Sleep 10 2373 | } 2374 | 2375 | # Reconnect with Application permissions 2376 | Try {Disconnect-MgGraph -ErrorAction Stop | Out-Null} Catch {} 2377 | if ($GraphClientId) { 2378 | $GraphCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $GraphClientId, $SSCred 2379 | } else { 2380 | $GraphCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ($EntraApp.AppId), $SSCred 2381 | } 2382 | 2383 | $ConnCount = 0 2384 | Write-Host "$(Get-Date) Connecting to Graph with application authentication..." 2385 | Do { 2386 | Try { 2387 | $ConnCount++ 2388 | if ($ConnCount -gt 5) {$ConnectionVerbose = @{Verbose = $true}} # Suppress Verbose output for the first 5 attempts, but display when connection is taking longer 2389 | Write-Verbose "$(Get-Date) Graph connection attempt #$ConnCount" @ConnectionVerbose 2390 | Connect-MgGraph -TenantId $tenantdomain -ClientSecretCredential $GraphCred -Environment $cloud -ContextScope "Process" -ErrorAction Stop | Out-Null 2391 | } Catch { 2392 | Start-Sleep 5 2393 | } 2394 | } Until ($null -ne (Get-MgContext)) 2395 | 2396 | # If the Delegated permissions were skipped, then the EntraApp has not yet been collected. Specifying the App ID allows the Application.ReadWrite.OwnedBy permission to be sufficient. 2397 | if ($GraphClientId -and $PromptForApplicationSecret) { 2398 | $EntraApp = Invoke-MgGraphRequest -Method GET -Uri "$GraphHost/v1.0/applications(appId=`'$GraphClientId`')" 2399 | } 2400 | 2401 | # Check if redirect URIs not set for existing app because DoNotRemediate is True. Needs to be evaluated after switching to Application permissions for scenarios where Delegated is not used. 2402 | $webRUri = @("https://security.optimization.assessment.local","https://o365soa.github.io/soa/") 2403 | if (($EntraApp.PublicClient.RedirectUris -notcontains 'https://login.microsoftonline.com/common/oauth2/nativeclient' -or (Compare-Object -ReferenceObject $EntraApp.Web.RedirectUris -DifferenceObject $webRUri)) -and $DoNotRemediate) { 2404 | # Fail the Entra app check 2405 | $CheckResults += New-Object -Type PSObject -Property @{ 2406 | Check="Entra Application" 2407 | Pass=$false 2408 | } 2409 | } 2410 | else { 2411 | # Pass the Entra app check 2412 | $CheckResults += New-Object -Type PSObject -Property @{ 2413 | Check="Entra Application" 2414 | Pass=$true 2415 | } 2416 | } 2417 | $CheckResults += New-Object -Type PSObject -Property @{ 2418 | Check="Entra Application Owner" 2419 | Pass=$script:appSelfOwner 2420 | } 2421 | 2422 | $AppTest = Test-SOAApplication -App $EntraApp -Secret $clientsecret -TenantDomain $tenantdomain -CloudEnvironment $CloudEnvironment -WriteHost 2423 | 2424 | # Entra App Permission - Perform remediation if specified 2425 | If($AppTest.Permissions -eq $False -and $Remediate -eq $true) 2426 | { 2427 | # Set up the correct Entra App Permissions 2428 | Write-Host "$(Get-Date) Remediating application permissions..." 2429 | Write-Host "$(Get-Date) Reconnecting to Graph with delegated authentication..." 2430 | # No scopes need to be explicitly requested here because the user will have already consented to them in the previous delegated connection 2431 | Connect-MgGraph -Environment $cloud -ContextScope "Process" | Out-Null 2432 | If((Set-EntraAppPermission -App $EntraApp -PerformConsent:$True -CloudEnvironment $CloudEnvironment) -eq $True) { 2433 | # Perform check again after setting permissions 2434 | $ConnCount = 0 2435 | Write-Host "$(Get-Date) Reconnecting to Graph with application authentication..." 2436 | Do { 2437 | Try { 2438 | $ConnCount++ 2439 | Write-Verbose "$(Get-Date) Graph connection attempt #$ConnCount" 2440 | Connect-MgGraph -TenantId $tenantdomain -ClientSecretCredential $GraphCred -Environment $cloud -ContextScope "Process" -ErrorAction Stop | Out-Null 2441 | } Catch { 2442 | Start-Sleep 5 2443 | } 2444 | } Until ($null -ne (Get-MgContext)) 2445 | $AppTest = Test-SOAApplication -App $EntraApp -Secret $clientsecret -TenantDomain $tenantdomain -CloudEnvironment $CloudEnvironment -WriteHost 2446 | } 2447 | } 2448 | 2449 | If($AppTest.Token -eq $False) 2450 | { 2451 | Write-Host "$(Get-Date) Missing roles in access token; possible that consent was not completed..." 2452 | if ($Remediate -eq $true) { 2453 | # Request admin consent 2454 | If((Invoke-Consent -App $EntraApp -CloudEnvironment $CloudEnvironment) -eq $True) { 2455 | # Perform check again after consent 2456 | $AppTest = Test-SOAApplication -App $EntraApp -Secret $clientsecret -TenantDomain $tenantdomain -CloudEnvironment $CloudEnvironment -WriteHost 2457 | } 2458 | } 2459 | } 2460 | 2461 | # Add final result to CheckResults object 2462 | $CheckResults += New-Object -Type PSObject -Property @{ 2463 | Check="Entra App Permissions" 2464 | Pass=$AppTest.Permissions 2465 | } 2466 | $CheckResults += New-Object -Type PSObject -Property @{ 2467 | Check="Entra App Role Consent" 2468 | Pass=$AppTest.Token 2469 | } 2470 | 2471 | Write-Host "$(Get-Date) Performing Graph Test..." 2472 | # Perform Graph check using credentials on the App 2473 | if ($null -ne (Get-MgContext)){Disconnect-MgGraph | Out-Null} 2474 | Start-Sleep 10 # Avoid a race condition 2475 | Connect-MgGraph -TenantId $tenantdomain -ClientSecretCredential $GraphCred -Environment $cloud -ErrorAction SilentlyContinue -ErrorVariable ConnectError | Out-Null 2476 | 2477 | If($ConnectError){ 2478 | # Try again to confirm it wasn't a transient issue 2479 | Write-Verbose "$(Get-Date) Error when connecting using Graph SDK. Retrying in 15 seconds" 2480 | Start-Sleep 15 2481 | Connect-MgGraph -TenantId $tenantdomain -ClientSecretCredential $GraphCred -Environment $cloud -ErrorAction SilentlyContinue -ErrorVariable ConnectError2 | Out-Null 2482 | if ($ConnectError2) { 2483 | $CheckResults += New-Object -Type PSObject -Property @{ 2484 | Check="Graph SDK Connection" 2485 | Pass=$False 2486 | } 2487 | } else { 2488 | $CheckResults += New-Object -Type PSObject -Property @{ 2489 | Check="Graph SDK Connection" 2490 | Pass=$True 2491 | } 2492 | } 2493 | } 2494 | else { 2495 | $CheckResults += New-Object -Type PSObject -Property @{ 2496 | Check="Graph SDK Connection" 2497 | Pass=$True 2498 | } 2499 | 2500 | if ($PromptForApplicationSecret -eq $false) { 2501 | Start-Sleep 10 # Avoid a race condition 2502 | # Remove client secret 2503 | Remove-SOAAppSecret 2504 | } 2505 | # Disconnect 2506 | Disconnect-MgGraph | Out-Null 2507 | } 2508 | } 2509 | Else 2510 | { 2511 | # Entra application does not exist 2512 | $CheckResults += New-Object -Type PSObject -Property @{ 2513 | Check="Entra Application" 2514 | Pass=$False 2515 | } 2516 | } 2517 | 2518 | } 2519 | 2520 | Write-Host "$(Get-Date) Detailed Output" 2521 | 2522 | If($ModuleCheck -eq $True) 2523 | { 2524 | 2525 | Write-Host "$(Get-Date) Installed Modules" -ForegroundColor Green 2526 | if ($RemoveMultipleModuleVersions) { 2527 | $Modules_OK | Format-Table Module,InstalledVersion,GalleryVersion,Multiple,NewerAvailable 2528 | } 2529 | else { 2530 | $Modules_OK | Format-Table Module,InstalledVersion,GalleryVersion,NewerAvailable 2531 | } 2532 | 2533 | If($Modules_Error.Count -gt 0) 2534 | { 2535 | Write-Host "$(Get-Date) Modules with errors" -ForegroundColor Red 2536 | if ($RemoveMultipleModuleVersions) { 2537 | $Modules_Error | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,Multiple,NewerAvailable 2538 | } 2539 | else { 2540 | $Modules_Error | Format-Table Module,InstalledVersion,GalleryVersion,Conflict,NewerAvailable 2541 | } 2542 | 2543 | $CheckResults += New-Object -TypeName PSObject -Property @{ 2544 | Check="Module Installation" 2545 | Pass=$False 2546 | } 2547 | 2548 | } 2549 | Else 2550 | { 2551 | $CheckResults += New-Object -TypeName PSObject -Property @{ 2552 | Check="Module Installation" 2553 | Pass=$True 2554 | } 2555 | } 2556 | 2557 | } 2558 | 2559 | If($ConnectCheck -eq $True) 2560 | { 2561 | 2562 | Write-Host "$(Get-Date) Connections" -ForegroundColor Green 2563 | $Connections_OK | Format-Table Name,Connected,TestCommand 2564 | 2565 | If($Connections_Error.Count -gt 0) { 2566 | Write-Host "$(Get-Date) Connections with errors" -ForegroundColor Red 2567 | $Connections_Error | Format-Table Name,Connected,TestCommand 2568 | $CheckResults += New-Object -Type PSObject -Property @{ 2569 | Check="Module Connections" 2570 | Pass=$False 2571 | } 2572 | } Else { 2573 | $CheckResults += New-Object -Type PSObject -Property @{ 2574 | Check="Module Connections" 2575 | Pass=$True 2576 | } 2577 | } 2578 | 2579 | } 2580 | 2581 | If($EntraAppCheck -eq $True) { 2582 | 2583 | Write-Host "$(Get-Date) Microsoft Entra enterprise application checks" -ForegroundColor Green 2584 | 2585 | } 2586 | 2587 | Write-Host "$(Get-Date) Summary of Checks" 2588 | 2589 | $CheckResults | Format-Table Check,Pass 2590 | 2591 | $SOAModule = Get-Module SOA 2592 | if ($SOAModule) { 2593 | $version = $SOAModule.Version.ToString() 2594 | } 2595 | 2596 | [ordered]@{ 2597 | Date=(Get-Date).DateTime 2598 | Version=$version 2599 | Cloud=$CloudEnvironment 2600 | UserCount=$AppTest.UserCount 2601 | UserCountNote=$AppTest.CountNote 2602 | Results=$CheckResults 2603 | ModulesOK=$Modules_OK 2604 | ModulesError=$Modules_Error 2605 | ConnectionsOK=$Connections_OK 2606 | ConnectionsError=$Connections_Error 2607 | } | ConvertTo-Json | Out-File SOA-PreCheck.json 2608 | 2609 | Write-Host "$(Get-Date) Output saved to SOA-PreCheck.json which should be sent to the engineer who will be performing the assessment." 2610 | $CurrentDir = Get-Location 2611 | Write-Host "$(Get-Date) SOA-PreCheck.json is located in: " -NoNewline 2612 | Write-Host "$CurrentDir" -ForegroundColor Yellow 2613 | Write-Host "" 2614 | 2615 | While($True) { 2616 | $rhInput = Read-Host "Type 'yes' when you have sent the SOA-PreCheck.json file to the engineer who will be performing the assessment." 2617 | if($rhInput -eq "yes") { 2618 | break; 2619 | } 2620 | } 2621 | 2622 | Exit-Script 2623 | } 2624 | --------------------------------------------------------------------------------