├── .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 |
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 |
--------------------------------------------------------------------------------