309 lines
18 KiB
PowerShell
309 lines
18 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
Perform a CIS‑aligned security audit of a Microsoft 365 tenant.
|
||
.DESCRIPTION
|
||
Invoke-M365SecurityAudit runs a series of CIS benchmark tests (v3.0.0 or v4.0.0) against your
|
||
Microsoft 365 environment. You can filter by domain, license level (E3/E5), profile level (L1/L2),
|
||
IG levels, include or skip specific recommendations, and supply app‑based credentials.
|
||
Results are returned as an array of CISAuditResult objects.
|
||
.PARAMETER TenantAdminUrl
|
||
The SharePoint admin URL (e.g. https://contoso-admin.sharepoint.com). If omitted, SPO tests are skipped.
|
||
.PARAMETER DomainName
|
||
Limit domain‐specific tests (1.3.1, 2.1.9) to this domain (e.g. “contoso.com”).
|
||
.PARAMETER ELevel
|
||
License audit level (“E3” or “E5”). Requires -ProfileLevel to also be specified.
|
||
.PARAMETER ProfileLevel
|
||
CIS profile level (“L1” or “L2”). Mandatory when -ELevel is used.
|
||
.PARAMETER IncludeIG1
|
||
Include IG1‐only tests in the audit.
|
||
.PARAMETER IncludeIG2
|
||
Include IG2‐only tests in the audit.
|
||
.PARAMETER IncludeIG3
|
||
Include IG3‐only tests in the audit.
|
||
.PARAMETER IncludeRecommendation
|
||
An array of specific recommendation IDs to include (e.g. '1.1.3','2.1.1').
|
||
.PARAMETER SkipRecommendation
|
||
An array of specific recommendation IDs to exclude.
|
||
.PARAMETER ApprovedCloudStorageProviders
|
||
For test 8.1.1, list allowed storage providers (‘GoogleDrive’,’Box’,’ShareFile’,’DropBox’,’Egnyte’).
|
||
.PARAMETER ApprovedFederatedDomains
|
||
For test 8.2.1, list allowed federated domains (e.g. 'microsoft.com').
|
||
.PARAMETER DoNotConnect
|
||
Skip connecting to Microsoft 365 services; you must have an existing session.
|
||
.PARAMETER DoNotDisconnect
|
||
Skip disconnecting from Microsoft 365 services at the end.
|
||
.PARAMETER NoModuleCheck
|
||
Skip installing/checking required PowerShell modules.
|
||
.PARAMETER DoNotConfirmConnections
|
||
When connecting, do not prompt for “Proceed?” before authenticating.
|
||
.PARAMETER AuthParams
|
||
A CISAuthenticationParameters object for certificate‑based app authentication.
|
||
.PARAMETER Version
|
||
CIS definitions version (“3.0.0” or “4.0.0”; default “4.0.0”).
|
||
.INPUTS
|
||
None; this cmdlet does not accept pipeline input.
|
||
.OUTPUTS
|
||
CISAuditResult[] — an array of PSCustomObjects representing each test’s outcome.
|
||
.EXAMPLE
|
||
# Quick audit with defaults (v4.0.0)
|
||
Invoke-M365SecurityAudit
|
||
.EXAMPLE
|
||
# Audit E5, level L1, for a single domain:
|
||
Invoke-M365SecurityAudit -TenantAdminUrl 'https://contoso-admin.sharepoint.com' `
|
||
-DomainName 'contoso.com' -ELevel E5 -ProfileLevel L1
|
||
.EXAMPLE
|
||
# Only include specific recommendations:
|
||
Invoke-M365SecurityAudit -IncludeRecommendation '1.1.3','2.1.1'
|
||
.EXAMPLE
|
||
# App‑only auth + skip confirmation:
|
||
$auth = New-M365SecurityAuditAuthObject -ClientId ... -ClientCertThumbPrint ...
|
||
Invoke-M365SecurityAudit -AuthParams $auth -DoNotConfirmConnections
|
||
.LINK
|
||
https://criticalsolutionsnetwork.github.io/M365FoundationsCISReport/#Invoke-M365SecurityAudit
|
||
#>
|
||
|
||
function Invoke-M365SecurityAudit {
|
||
# Add confirm to high
|
||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High' , DefaultParameterSetName = 'Default')]
|
||
[OutputType([CISAuditResult[]])]
|
||
param (
|
||
[Parameter(Mandatory = $false, HelpMessage = "The SharePoint tenant admin URL, which should end with '-admin.sharepoint.com'. If not specified none of the Sharepoint Online tests will run.")]
|
||
[ValidatePattern('^https://[a-zA-Z0-9-]+-admin\.sharepoint\.com$')]
|
||
[string]
|
||
$TenantAdminUrl,
|
||
[Parameter(Mandatory = $false, HelpMessage = "Specify this to test only the default domain for password expiration and DKIM Config for tests '1.3.1' and 2.1.9. The domain name of your organization, e.g., 'example.com'.")]
|
||
[ValidatePattern('^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$')]
|
||
[string]
|
||
$DomainName,
|
||
# E-Level with optional ProfileLevel selection
|
||
[Parameter(Mandatory = $true, ParameterSetName = 'ELevelFilter', HelpMessage = 'Specifies the E-Level (E3 or E5) for the audit.')]
|
||
[ValidateSet('E3', 'E5')]
|
||
[string]
|
||
$ELevel,
|
||
[Parameter(Mandatory = $true, ParameterSetName = 'ELevelFilter', HelpMessage = 'Specifies the profile level (L1 or L2) for the audit.')]
|
||
[ValidateSet('L1', 'L2')]
|
||
[string]
|
||
$ProfileLevel,
|
||
# IG Filters, one at a time
|
||
[Parameter(Mandatory = $true, ParameterSetName = 'IG1Filter', HelpMessage = 'Includes tests where IG1 is true.')]
|
||
[switch]
|
||
$IncludeIG1,
|
||
[Parameter(Mandatory = $true, ParameterSetName = 'IG2Filter', HelpMessage = 'Includes tests where IG2 is true.')]
|
||
[switch]
|
||
$IncludeIG2,
|
||
[Parameter(Mandatory = $true, ParameterSetName = 'IG3Filter', HelpMessage = 'Includes tests where IG3 is true.')]
|
||
[switch]
|
||
$IncludeIG3,
|
||
# Inclusion of specific recommendation numbers
|
||
[Parameter(Mandatory = $true, ParameterSetName = 'RecFilter', HelpMessage = 'Specifies specific recommendations to include in the audit. Accepts an array of recommendation numbers.')]
|
||
[ValidateSet(
|
||
'1.1.1', '1.1.3', '1.1.4', '1.2.1', '1.2.2', '1.3.1', '1.3.3', '1.3.6', '2.1.1', '2.1.2', `
|
||
'2.1.3', '2.1.4', '2.1.5', '2.1.6', '2.1.7', '2.1.9', '2.1.11', '2.1.12', '2.1.13', `
|
||
'2.1.14', '3.1.1', '5.1.2.3', '5.1.8.1', '6.1.1', '6.1.2', '6.1.3', '6.1.4', '6.2.1', `
|
||
'6.2.2', '6.2.3', '6.3.1', '6.5.1', '6.5.2', '6.5.3', '7.2.1', '7.2.10', '7.2.2', `
|
||
'7.2.3', '7.2.4', '7.2.5', '7.2.6', '7.2.7', '7.2.9', '7.3.1', '7.3.2', '7.3.4', `
|
||
'8.1.1', '8.1.2', '8.2.1', '8.5.1', '8.5.2', '8.5.3', '8.5.4', '8.5.5', '8.5.6', `
|
||
'8.5.7', '8.6.1'
|
||
)]
|
||
[string[]]
|
||
$IncludeRecommendation,
|
||
# Exclusion of specific recommendation numbers
|
||
[Parameter(Mandatory = $true, ParameterSetName = 'SkipRecFilter', HelpMessage = 'Specifies specific recommendations to exclude from the audit. Accepts an array of recommendation numbers.')]
|
||
[ValidateSet(
|
||
'1.1.1', '1.1.3', '1.1.4', '1.2.1', '1.2.2', '1.3.1', '1.3.3', '1.3.6', '2.1.1', '2.1.2', `
|
||
'2.1.3', '2.1.4', '2.1.5', '2.1.6', '2.1.7', '2.1.9', '2.1.11', '2.1.12', '2.1.13', `
|
||
'2.1.14', '3.1.1', '5.1.2.3', '5.1.8.1', '6.1.1', '6.1.2', '6.1.3', '6.1.4', '6.2.1', `
|
||
'6.2.2', '6.2.3', '6.3.1', '6.5.1', '6.5.2', '6.5.3', '7.2.1', '7.2.10', '7.2.2', `
|
||
'7.2.3', '7.2.4', '7.2.5', '7.2.6', '7.2.7', '7.2.9', '7.3.1', '7.3.2', '7.3.4', `
|
||
'8.1.1', '8.1.2', '8.2.1', '8.5.1', '8.5.2', '8.5.3', '8.5.4', '8.5.5', '8.5.6', `
|
||
'8.5.7', '8.6.1'
|
||
)]
|
||
[string[]]
|
||
$SkipRecommendation,
|
||
# Common parameters for all parameter sets
|
||
[Parameter(Mandatory = $false, HelpMessage = 'Specifies the approved cloud storage providers for the audit. Accepts an array of cloud storage provider names.')]
|
||
[ValidateSet(
|
||
'GoogleDrive', 'ShareFile', 'Box', 'DropBox', 'Egnyte'
|
||
)]
|
||
[string[]]
|
||
$ApprovedCloudStorageProviders = @(),
|
||
[Parameter(Mandatory = $false, HelpMessage = 'Specifies the approved federated domains for the audit test 8.2.1. Accepts an array of allowed domain names.')]
|
||
[ValidatePattern('^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$')]
|
||
[string[]]
|
||
$ApprovedFederatedDomains,
|
||
[Parameter(Mandatory = $false, HelpMessage = 'Specifies that the cmdlet will not establish a connection to Microsoft 365 services.')]
|
||
[switch]
|
||
$DoNotConnect,
|
||
[Parameter(Mandatory = $false, HelpMessage = 'Specifies that the cmdlet will not disconnect from Microsoft 365 services after execution.')]
|
||
[switch]
|
||
$DoNotDisconnect,
|
||
[Parameter(Mandatory = $false, HelpMessage = 'Specifies that the cmdlet will not check for the presence of required modules.')]
|
||
[switch]
|
||
$NoModuleCheck,
|
||
[Parameter(Mandatory = $false, HelpMessage = 'Specifies that the cmdlet will not prompt for confirmation before proceeding with established connections and will disconnect from all of them.')]
|
||
[switch]
|
||
$DoNotConfirmConnections,
|
||
[Parameter(Mandatory = $false, HelpMessage = 'Specifies an authentication object containing parameters for application-based authentication.')]
|
||
[CISAuthenticationParameters]
|
||
$AuthParams,
|
||
[Parameter(Mandatory = $false, HelpMessage = "Specifies the CIS benchmark definitions version to use. Default is 4.0.0. Valid values are '3.0.0' or '4.0.0'.")]
|
||
[ValidateSet('3.0.0', '4.0.0')]
|
||
[string]
|
||
$Version = '4.0.0'
|
||
)
|
||
begin {
|
||
if ($script:MaximumFunctionCount -lt 8192) {
|
||
Write-Verbose "Setting the `$script:MaximumFunctionCount to 8192 for the test run."
|
||
$script:MaximumFunctionCount = 8192
|
||
}
|
||
if ($AuthParams) {
|
||
$script:PnpAuth = $true
|
||
$defaultPNPUpdateCheck = $env:PNPPOWERSHELL_UPDATECHECK
|
||
$env:PNPPOWERSHELL_UPDATECHECK = 'Off'
|
||
}
|
||
# Check for 4.0.0 specific tests when in 3.0.0 mode
|
||
# Test variables for testing 3.0.0 specific tests for included 4.0.0 tests
|
||
$recNumbersToCheck = @('1.1.4', '2.1.11', '2.1.12', '2.1.13', '2.1.14', '6.1.4')
|
||
# $IncludeRecommendation = '1.1.1','1.1.4'
|
||
# $Version = '3.0.0'
|
||
if ($IncludeRecommendation) {
|
||
if ($Version -ne '4.0.0') {
|
||
$foundRecNumbers = @()
|
||
foreach ($rec in $recNumbersToCheck) {
|
||
if ($IncludeRecommendation -contains $rec) {
|
||
$foundRecNumbers += $rec
|
||
}
|
||
}
|
||
if ($foundRecNumbers.Count -gt 0) {
|
||
throw "Check the '-IncludeRecommendation' parameter. The following test numbers are not available in the 3.0.0 version: $($foundRecNumbers -join ', ')"
|
||
}
|
||
}
|
||
}
|
||
# Ensure required modules are installed
|
||
$requiredModules = Get-RequiredModule -AuditFunction
|
||
# Format the required modules list
|
||
$requiredModulesFormatted = Format-RequiredModuleList -RequiredModules $requiredModules
|
||
# Check and install required modules if necessary
|
||
if (!($NoModuleCheck) -and $PSCmdlet.ShouldProcess("Install Modules: $requiredModulesFormatted", 'Assert-ModuleAvailability')) {
|
||
Write-Information 'Checking for and installing required modules...'
|
||
foreach ($module in $requiredModules) {
|
||
Assert-ModuleAvailability -ModuleName $module.ModuleName -RequiredVersion $module.RequiredVersion -SubModules $module.SubModules
|
||
}
|
||
}
|
||
elseif ($script:PnpAuth = $true) {
|
||
# Ensure MgGraph assemblies are loaded prior to running PnP cmdlets
|
||
Get-MgGroup -Top 1 -ErrorAction SilentlyContinue | Out-Null
|
||
}
|
||
$Script:CISVersion = $Version
|
||
# Call the function to load and merge test definitions
|
||
$testDefinitions = Get-TestDefinition -Version $Version
|
||
# Load the Test Definitions into the script scope for use in other functions
|
||
$script:TestDefinitionsObject = $testDefinitions
|
||
# Apply filters based on parameter sets
|
||
$params = @{
|
||
TestDefinitions = $testDefinitions
|
||
ParameterSetName = $PSCmdlet.ParameterSetName
|
||
ELevel = $ELevel
|
||
ProfileLevel = $ProfileLevel
|
||
IncludeRecommendation = $IncludeRecommendation
|
||
SkipRecommendation = $SkipRecommendation
|
||
}
|
||
$testDefinitions = Get-TestDefinitionsObject @params
|
||
# Extract unique connections needed
|
||
$requiredConnections = $testDefinitions.Connection | Sort-Object -Unique
|
||
if ($requiredConnections -contains 'SPO') {
|
||
if (-not $TenantAdminUrl) {
|
||
$requiredConnections = $requiredConnections | Where-Object { $_ -ne 'SPO' }
|
||
$testDefinitions = $testDefinitions | Where-Object { $_.Connection -ne 'SPO' }
|
||
if ($null -eq $testDefinitions) {
|
||
throw 'No tests to run as no SharePoint Online tests are available.'
|
||
}
|
||
}
|
||
}
|
||
# Determine which test files to load based on filtering
|
||
$testsToLoad = $testDefinitions.TestFileName | ForEach-Object { $_ -replace '.ps1$', '' }
|
||
Write-Verbose "The $(($testsToLoad).count) test/s that would be loaded based on filter criteria:"
|
||
$testsToLoad | ForEach-Object { Write-Verbose " $_" }
|
||
# Initialize a collection to hold failed test details
|
||
$script:FailedTests = [System.Collections.ArrayList]::new()
|
||
} # End Begin
|
||
process {
|
||
$allAuditResults = [System.Collections.ArrayList]::new() # Initialize a collection to hold all results
|
||
# Dynamically dot-source the test scripts
|
||
$testsFolderPath = Join-Path -Path $PSScriptRoot -ChildPath 'tests'
|
||
$testFiles = Get-ChildItem -Path $testsFolderPath -Filter 'Test-*.ps1' |
|
||
Where-Object { $testsToLoad -contains $_.BaseName }
|
||
$totalTests = $testFiles.Count
|
||
$currentTestIndex = 0
|
||
# Establishing connections if required
|
||
try {
|
||
$actualUniqueConnections = Get-UniqueConnection -Connections $requiredConnections
|
||
if (!($DoNotConnect) -and $PSCmdlet.ShouldProcess("Establish connections to Microsoft 365 services: $($actualUniqueConnections -join ', ')", 'Connect')) {
|
||
Write-Information "Establishing connections to Microsoft 365 services: $($actualUniqueConnections -join ', ')"
|
||
Connect-M365Suite -TenantAdminUrl $TenantAdminUrl -RequiredConnections $requiredConnections -SkipConfirmation:$DoNotConfirmConnections -AuthParams $AuthParams
|
||
}
|
||
}
|
||
catch {
|
||
throw "Connection execution aborted: $_"
|
||
}
|
||
}
|
||
end {
|
||
try {
|
||
if ($PSCmdlet.ShouldProcess("Measure and display audit results for $($totalTests) tests", 'Measure')) {
|
||
Write-Information "A total of $($totalTests) tests were selected to run..."
|
||
# Import the test functions
|
||
$testFiles | ForEach-Object {
|
||
$currentTestIndex++
|
||
Write-Progress -Activity 'Loading Test Scripts' -Status "Loading $($currentTestIndex) of $($totalTests): $($_.Name)" -PercentComplete (($currentTestIndex / $totalTests) * 100)
|
||
try {
|
||
# Dot source the test function
|
||
. $_.FullName
|
||
}
|
||
catch {
|
||
# Log the error and add the test to the failed tests collection
|
||
Write-Verbose "Failed to load test function $($_.Name): $_"
|
||
$script:FailedTests.Add([PSCustomObject]@{ Test = $_.Name; Error = $_ })
|
||
}
|
||
}
|
||
$currentTestIndex = 0
|
||
# Execute each test function from the prepared list
|
||
foreach ($testFunction in $testFiles) {
|
||
$currentTestIndex++
|
||
Write-Progress -Activity 'Executing Tests' -Status "Executing $($currentTestIndex) of $($totalTests): $($testFunction.Name)" -PercentComplete (($currentTestIndex / $totalTests) * 100)
|
||
$functionName = $testFunction.BaseName
|
||
Write-Information "Executing test function: $functionName"
|
||
$auditResult = Invoke-TestFunction -FunctionFile $testFunction -DomainName $DomainName -ApprovedCloudStorageProviders $ApprovedCloudStorageProviders -ApprovedFederatedDomains $ApprovedFederatedDomains
|
||
# Add the result to the collection
|
||
[void]$allAuditResults.Add($auditResult)
|
||
}
|
||
# Call the private function to calculate and display results
|
||
Measure-AuditResult -AllAuditResults $allAuditResults -FailedTests $script:FailedTests
|
||
# Return all collected audit results
|
||
# Define the test numbers to check
|
||
$TestNumbersToCheck = '1.1.1', '1.3.1', '6.1.2', '6.1.3', '7.3.4'
|
||
# Check for large details in the audit results
|
||
$exceedingTests = Get-ExceededLengthResultDetail -AuditResults $allAuditResults -TestNumbersToCheck $TestNumbersToCheck -ReturnExceedingTestsOnly -DetailsLengthLimit 30000
|
||
if ($exceedingTests.Count -gt 0) {
|
||
Write-Information "The following tests exceeded the details length limit: $($exceedingTests -join ', ')"
|
||
Write-Information "( Assuming the results were instantiated. Ex: `$object = invoke-M365SecurityAudit )`nUse the following command and adjust as necessary to view the full details of the test results:"
|
||
Write-Information "Export-M365SecurityAuditTable -ExportAllTests -AuditResults `$object -ExportPath `"C:\temp`" -ExportOriginalTests"
|
||
}
|
||
# return $allAuditResults.ToArray() | Sort-Object -Property Rec
|
||
# TODO Check if this fixes export-table.
|
||
return $allAuditResults | Sort-Object -Property Rec
|
||
}
|
||
}
|
||
catch {
|
||
# Log the error and add the test to the failed tests collection
|
||
throw "Failed to execute test function $($testFunction.Name): $_"
|
||
$script:FailedTests.Add([PSCustomObject]@{ Test = $_.Name; Error = $_ })
|
||
}
|
||
finally {
|
||
$env:PNPPOWERSHELL_UPDATECHECK = $defaultPNPUpdateCheck
|
||
if (!($DoNotDisconnect) -and $PSCmdlet.ShouldProcess("Disconnect from Microsoft 365 services: $($actualUniqueConnections -join ', ')", 'Disconnect')) {
|
||
# Clean up sessions
|
||
Disconnect-M365Suite -RequiredConnections $requiredConnections
|
||
}
|
||
}
|
||
}
|
||
} |