d3e0769799
- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root; Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/ - Add AGENTS.md with project architecture, entry points, and security notes - Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts - Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport, Export-ObjectInventoryReport) and CA wizard helpers - Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath, and optimized group loading - Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry - Update Extensions for Settings Catalog definition auto-export - Update README with v4.1.0, new entry points and script catalog - Bump VERSION to 4.1.0 - Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports, Settings.json and IntuneManagement.log
700 lines
36 KiB
PowerShell
700 lines
36 KiB
PowerShell
<#PSScriptInfo
|
|
.VERSION 1.0.0
|
|
.GUID 9f3c2a8b-7e1d-4f5a-9b2c-8d3e4f5a6b7c
|
|
.AUTHOR IntuneManagement Toolkit
|
|
.COMPANYNAME
|
|
.COPYRIGHT
|
|
.TAGS CIS,M365,Security,Baseline,EntraID,Defender,Exchange,SharePoint,Teams
|
|
.LICENSEURI
|
|
.PROJECTURI
|
|
.ICONURI
|
|
.EXTERNALMODULEDEPENDENCIES Microsoft.Graph,ExchangeOnlineManagement,PnP.PowerShell,MicrosoftTeams
|
|
.REQUIREDSCRIPTS
|
|
.EXTERNALSCRIPTDEPENDENCIES
|
|
.RELEASENOTES
|
|
v1.0.0 - Initial rapid baseline for CIS M365 Foundations alignment on greenfield/newly-acquired tenants.
|
|
#>
|
|
|
|
<#
|
|
.SYNOPSIS
|
|
Rapidly deploys (or assesses) a high-impact CIS M365-aligned baseline to a new or newly-acquired tenant.
|
|
|
|
.DESCRIPTION
|
|
This script targets the ~40 highest-impact, easily-automated CIS M365 controls across:
|
|
- Entra ID (password policies, auth methods, Conditional Access)
|
|
- Microsoft Defender for Office 365 (Safe Links, Safe Attachments, Anti-Phish)
|
|
- Exchange Online (external forwarding block, mailbox auditing)
|
|
- SharePoint Online / OneDrive (external sharing restrictions)
|
|
- Microsoft Teams (anonymous meeting restrictions, federation)
|
|
|
|
It is designed for NEW or NEWLY-ACQUIRED tenants where disruption risk is low.
|
|
On established tenants, run in -Mode Assess first and review every change.
|
|
|
|
DEFAULT BEHAVIOUR IS READ-ONLY (-Mode Assess). You must specify -Mode Deploy -Apply to make changes.
|
|
|
|
.PARAMETER Mode
|
|
Assess = Read-only audit against the baseline (default)
|
|
Deploy = Apply the baseline configuration
|
|
|
|
.PARAMETER ConfigPath
|
|
Path to the .psd1 configuration file. Defaults to .\CISM365-RapidBaseline.psd1
|
|
|
|
.PARAMETER Apply
|
|
Required switch when Mode is 'Deploy'. Prevents accidental execution.
|
|
|
|
.PARAMETER TenantId
|
|
Optional tenant ID for Graph authentication.
|
|
|
|
.PARAMETER SharePointAdminUrl
|
|
Optional SharePoint admin URL (e.g., https://contoso-admin.sharepoint.com).
|
|
If omitted, uses the value from the config file.
|
|
|
|
.PARAMETER Workloads
|
|
Array of workloads to process. Default is all.
|
|
Options: EntraID, ConditionalAccess, Defender, Exchange, SharePoint, Teams
|
|
|
|
.EXAMPLE
|
|
# Assess your tenant without making any changes
|
|
.\Deploy-CISM365RapidBaseline.ps1
|
|
|
|
.EXAMPLE
|
|
# Deploy the baseline after review
|
|
.\Deploy-CISM365RapidBaseline.ps1 -Mode Deploy -Apply -Verbose
|
|
|
|
.EXAMPLE
|
|
# Assess only Entra ID and Conditional Access
|
|
.\Deploy-CISM365RapidBaseline.ps1 -Workloads @('EntraID','ConditionalAccess')
|
|
#>
|
|
[CmdletBinding(SupportsShouldProcess)]
|
|
param(
|
|
[Parameter()]
|
|
[ValidateSet('Assess','Deploy')]
|
|
[string]$Mode = 'Assess',
|
|
|
|
[Parameter()]
|
|
[string]$ConfigPath = "$PSScriptRoot\CISM365-RapidBaseline.psd1",
|
|
|
|
[Parameter()]
|
|
[switch]$Apply,
|
|
|
|
[Parameter()]
|
|
[string]$TenantId,
|
|
|
|
[Parameter()]
|
|
[string]$SharePointAdminUrl,
|
|
|
|
[Parameter()]
|
|
[ValidateSet('EntraID','ConditionalAccess','Defender','Exchange','SharePoint','Teams')]
|
|
[string[]]$Workloads = @('EntraID','ConditionalAccess','Defender','Exchange','SharePoint','Teams')
|
|
)
|
|
|
|
#region Initialization
|
|
$ErrorActionPreference = 'Stop'
|
|
$script:Results = [System.Collections.Generic.List[object]]::new()
|
|
$script:ChangesMade = 0
|
|
$script:ChangesSkipped = 0
|
|
$script:Errors = 0
|
|
|
|
function Write-SectionHeader {
|
|
param([string]$Title)
|
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
|
Write-Host " $Title" -ForegroundColor Cyan
|
|
Write-Host "========================================" -ForegroundColor Cyan
|
|
}
|
|
|
|
function Add-Result {
|
|
param(
|
|
[string]$Workload,
|
|
[string]$Control,
|
|
[string]$Status, # Pass, Fail, Fixed, Skipped, Error
|
|
[string]$Message,
|
|
[string]$Remediation = ''
|
|
)
|
|
$script:Results.Add([PSCustomObject]@{
|
|
Workload = $Workload
|
|
Control = $Control
|
|
Status = $Status
|
|
Message = $Message
|
|
Remediation = $Remediation
|
|
})
|
|
switch ($Status) {
|
|
'Fixed' { $script:ChangesMade++ }
|
|
'Skipped' { $script:ChangesSkipped++ }
|
|
'Error' { $script:Errors++ }
|
|
}
|
|
}
|
|
|
|
# Load configuration
|
|
if (-not (Test-Path $ConfigPath)) {
|
|
throw "Configuration file not found: $ConfigPath"
|
|
}
|
|
$Config = Import-PowerShellDataFile -Path $ConfigPath
|
|
$TenantDomain = $Config.Tenant.TenantDomain
|
|
if (-not $SharePointAdminUrl) { $SharePointAdminUrl = $Config.Tenant.SharePointAdminUrl }
|
|
$LicenseProfile = $Config.Tenant.LicenseProfile
|
|
#endregion
|
|
|
|
#region Authentication
|
|
Write-SectionHeader "Authentication"
|
|
|
|
# Microsoft Graph
|
|
Write-Host "Connecting to Microsoft Graph..." -NoNewline
|
|
$GraphScopes = @(
|
|
'Directory.Read.All','Directory.ReadWrite.All','Policy.Read.All','Policy.ReadWrite.ConditionalAccess',
|
|
'Organization.Read.All','Organization.ReadWrite.All','RoleManagement.ReadWrite.Directory',
|
|
'IdentityRiskyUser.Read.All','IdentityRiskEvent.Read.All'
|
|
)
|
|
if ($TenantId) {
|
|
Connect-MgGraph -Scopes ($GraphScopes -join ',') -TenantId $TenantId -NoWelcome
|
|
} else {
|
|
Connect-MgGraph -Scopes ($GraphScopes -join ',') -NoWelcome
|
|
}
|
|
Write-Host " OK" -ForegroundColor Green
|
|
|
|
# Exchange Online (includes Defender)
|
|
if ($Workloads -contains 'Defender' -or $Workloads -contains 'Exchange') {
|
|
Write-Host "Connecting to Exchange Online..." -NoNewline
|
|
Connect-ExchangeOnline -ShowBanner:$false
|
|
Write-Host " OK" -ForegroundColor Green
|
|
}
|
|
|
|
# SharePoint
|
|
if ($Workloads -contains 'SharePoint') {
|
|
Write-Host "Connecting to SharePoint Online..." -NoNewline
|
|
Connect-PnPOnline -Url $SharePointAdminUrl -Interactive
|
|
Write-Host " OK" -ForegroundColor Green
|
|
}
|
|
|
|
# Teams
|
|
if ($Workloads -contains 'Teams') {
|
|
Write-Host "Connecting to Microsoft Teams..." -NoNewline
|
|
Connect-MicrosoftTeams
|
|
Write-Host " OK" -ForegroundColor Green
|
|
}
|
|
#endregion
|
|
|
|
#region Helper Functions
|
|
function Test-IsGlobalAdmin {
|
|
$context = Get-MgContext
|
|
$myRoles = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($context.Account)'" -ExpandProperty RoleDefinition
|
|
return ($myRoles.RoleDefinition.DisplayName -contains 'Global Administrator')
|
|
}
|
|
|
|
function Invoke-WithErrorHandling {
|
|
param(
|
|
[string]$Workload,
|
|
[string]$Control,
|
|
[scriptblock]$Action,
|
|
[string]$Remediation = ''
|
|
)
|
|
try {
|
|
& $Action
|
|
} catch {
|
|
Add-Result -Workload $Workload -Control $Control -Status 'Error' -Message $_.Exception.Message -Remediation $Remediation
|
|
Write-Warning "[$Workload/$Control] ERROR: $_"
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Entra ID
|
|
if ($Workloads -contains 'EntraID') {
|
|
Write-SectionHeader "Entra ID / Identity"
|
|
|
|
# 1.3.1 - Password expiration
|
|
Invoke-WithErrorHandling -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' -Action {
|
|
$org = Get-MgOrganization
|
|
$currentPolicy = $org.PasswordPolicies
|
|
$desired = if ($Config.EntraID.PasswordExpiration -eq 'NeverExpire') { 'None' } else { 'PasswordExpiration' }
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
$pass = ($desired -eq 'None' -and $currentPolicy -contains 'DisablePasswordExpiration')
|
|
Add-Result -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' `
|
|
-Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Current policy: $currentPolicy | Desired: $($Config.EntraID.PasswordExpiration)" `
|
|
-Remediation "Set-MgOrganization -PasswordPolicies 'DisablePasswordExpiration'"
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set password expiration to $($Config.EntraID.PasswordExpiration)")) {
|
|
Update-MgOrganization -OrganizationId $org.Id -PasswordPolicies 'DisablePasswordExpiration'
|
|
Add-Result -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' -Status 'Fixed' -Message "Set to NeverExpire"
|
|
} else {
|
|
Add-Result -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
|
|
# 5.2.3.2 - Banned passwords
|
|
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Action {
|
|
$policy = Get-MgPolicyAuthenticationMethodPolicy | Select-Object -ExpandProperty AuthenticationMethodConfigurations | Where-Object { $_.Id -eq 'MicrosoftAuthenticator' }
|
|
# Banned password list is actually in directory settings
|
|
$settings = Get-MgDirectorySetting | Where-Object { $_.DisplayName -eq 'Password Rule Settings' }
|
|
if (-not $settings) {
|
|
$template = Get-MgDirectorySettingTemplate | Where-Object { $_.DisplayName -eq 'Password Rule Settings' }
|
|
$settings = New-MgDirectorySetting -TemplateId $template.Id
|
|
}
|
|
$currentList = ($settings.Values | Where-Object { $_.Name -eq 'BannedPasswordList' }).Value
|
|
$desiredList = $Config.EntraID.BannedPasswords -join ', '
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
$hasAll = ($Config.EntraID.BannedPasswords | ForEach-Object { $currentList -contains $_ }) -notcontains $false
|
|
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' `
|
|
-Status $(if ($hasAll) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Current: $currentList | Desired: $desiredList" `
|
|
-Remediation "Update-MgDirectorySetting -BannedPasswordList '$desiredList'"
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($TenantDomain, "Update banned password list")) {
|
|
$params = @{ BannedPasswordList = $desiredList; EnableBannedPasswordCheck = $true }
|
|
Update-MgDirectorySetting -DirectorySettingId $settings.Id -Values $params
|
|
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Status 'Fixed' -Message "Updated banned password list"
|
|
} else {
|
|
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
|
|
# 5.1.2.3 - Block tenant creation by non-admins
|
|
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' -Action {
|
|
$setting = Get-MgPolicyAuthorizationPolicy
|
|
$current = $setting.DefaultUserRolePermissions.AllowedToCreateTenants
|
|
$desired = -not $Config.EntraID.BlockTenantCreation
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
Add-Result -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' `
|
|
-Status $(if ($current -eq $desired) { 'Pass' } else { 'Fail' }) `
|
|
-Message "AllowedToCreateTenants = $current | Desired = $desired" `
|
|
-Remediation "Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{AllowedToCreateTenants=`$false}"
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set AllowedToCreateTenants = $desired")) {
|
|
Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateTenants = $desired }
|
|
Add-Result -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' -Status 'Fixed' -Message "Set to $desired"
|
|
} else {
|
|
Add-Result -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
|
|
# 5.1.2.6 - Disable LinkedIn
|
|
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.6-DisableLinkedIn' -Action {
|
|
$org = Get-MgOrganization
|
|
$current = $org.MarketingNotificationEmails -contains 'LinkedIn'
|
|
# LinkedIn setting is in directory settings
|
|
$setting = Get-MgDirectorySetting | Where-Object { $_.DisplayName -eq 'Consent Policy Settings' }
|
|
# Simplified check - actual LinkedIn config varies by tenant region
|
|
Add-Result -Workload 'EntraID' -Control '5.1.2.6-DisableLinkedIn' -Status 'Skipped' `
|
|
-Message "LinkedIn integration check requires UI validation or tenant-specific Graph path." `
|
|
-Remediation "Navigate to Entra admin center > Users > User settings > LinkedIn account connections"
|
|
}
|
|
|
|
# 5.1.4.2 - Max devices per user
|
|
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' -Action {
|
|
$setting = Get-MgPolicyDeviceRegistrationPolicy
|
|
$current = $setting.UserDeviceQuota
|
|
$desired = $Config.EntraID.MaxDevicesPerUser
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
Add-Result -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' `
|
|
-Status $(if ($current -le $desired) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Current quota: $current | Desired max: $desired" `
|
|
-Remediation "Update-MgPolicyDeviceRegistrationPolicy -UserDeviceQuota $desired"
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set max devices per user to $desired")) {
|
|
Update-MgPolicyDeviceRegistrationPolicy -UserDeviceQuota $desired
|
|
Add-Result -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' -Status 'Fixed' -Message "Set to $desired"
|
|
} else {
|
|
Add-Result -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
|
|
# 5.1.2.2 - Block user consent
|
|
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' -Action {
|
|
$policy = Get-MgPolicyAuthorizationPolicy
|
|
$current = $policy.DefaultUserRolePermissions.AllowedToCreateApps
|
|
$desired = -not $Config.EntraID.BlockUserConsent
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
Add-Result -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' `
|
|
-Status $(if ($current -eq $desired) { 'Pass' } else { 'Fail' }) `
|
|
-Message "AllowedToCreateApps = $current | Desired = $desired" `
|
|
-Remediation "Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{AllowedToCreateApps=`$false}"
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set AllowedToCreateApps = $desired")) {
|
|
Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateApps = $desired }
|
|
Add-Result -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' -Status 'Fixed' -Message "Set to $desired"
|
|
} else {
|
|
Add-Result -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Conditional Access
|
|
if ($Workloads -contains 'ConditionalAccess') {
|
|
Write-SectionHeader "Conditional Access"
|
|
|
|
foreach ($caPolicy in $Config.ConditionalAccess) {
|
|
$policyName = $caPolicy.Name
|
|
Invoke-WithErrorHandling -Workload 'ConditionalAccess' -Control $policyName -Action {
|
|
$existing = Get-MgIdentityConditionalAccessPolicy -Filter "displayName eq '$policyName'" -ErrorAction SilentlyContinue
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
if ($existing) {
|
|
$stateMatch = ($existing.State -eq $caPolicy.State)
|
|
Add-Result -Workload 'ConditionalAccess' -Control $policyName `
|
|
-Status $(if ($stateMatch) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Policy exists. State: $($existing.State) | Desired: $($caPolicy.State)" `
|
|
-Remediation "Review policy in Entra admin center > Protection > Conditional Access"
|
|
} else {
|
|
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Fail' `
|
|
-Message "Policy does not exist." `
|
|
-Remediation "Create policy '$policyName' via Entra admin center or Graph API"
|
|
}
|
|
} else {
|
|
if ($existing) {
|
|
if ($PSCmdlet.ShouldProcess($policyName, "Update Conditional Access policy state to $($caPolicy.State)")) {
|
|
Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $existing.Id -State $caPolicy.State
|
|
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Fixed' -Message "Updated state to $($caPolicy.State)"
|
|
} else {
|
|
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
# For Deploy mode without existing policy, we provide guidance rather than auto-creating
|
|
# because CA policies are complex and tenant-specific (groups, apps, exclusions)
|
|
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Skipped' `
|
|
-Message "Policy does not exist. Auto-creation of CA policies is intentionally manual to avoid lockouts." `
|
|
-Remediation "Use the sample JSON in this script's comments or build via Entra admin center, then re-run Assess."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Defender / Exchange
|
|
if ($Workloads -contains 'Defender') {
|
|
Write-SectionHeader "Defender for Office 365"
|
|
|
|
# Safe Links
|
|
Invoke-WithErrorHandling -Workload 'Defender' -Control '2.1.1-SafeLinks' -Action {
|
|
$policy = Get-SafeLinksPolicy -Identity $Config.Defender.SafeLinks.Name -ErrorAction SilentlyContinue
|
|
if ($Mode -eq 'Assess') {
|
|
if ($policy) {
|
|
$pass = $policy.EnableSafeLinksForEmail -and $policy.TrackClicks -and -not $policy.AllowClickThrough
|
|
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Safe Links policy exists. EmailProtection=$($policy.EnableSafeLinksForEmail) TrackClicks=$($policy.TrackClicks) AllowClickThrough=$($policy.AllowClickThrough)" `
|
|
-Remediation "Set-SafeLinksPolicy -Identity '$($Config.Defender.SafeLinks.Name)' -EnableSafeLinksForEmail `$true -TrackClicks `$true -AllowClickThrough `$false"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Fail' `
|
|
-Message "Safe Links policy '$($Config.Defender.SafeLinks.Name)' not found." `
|
|
-Remediation "New-SafeLinksPolicy (see script comments for full syntax)"
|
|
}
|
|
} else {
|
|
if ($policy) {
|
|
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeLinks.Name, 'Update Safe Links policy')) {
|
|
Set-SafeLinksPolicy -Identity $Config.Defender.SafeLinks.Name `
|
|
-EnableSafeLinksForEmail $Config.Defender.SafeLinks.Enabled `
|
|
-TrackClicks $Config.Defender.SafeLinks.TrackClicks `
|
|
-AllowClickThrough $Config.Defender.SafeLinks.AllowClickThrough `
|
|
-ScanUrls $Config.Defender.SafeLinks.ScanUrls `
|
|
-EnableForInternalSenders $Config.Defender.SafeLinks.EnableForInternalSenders
|
|
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Fixed' -Message "Updated Safe Links policy"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeLinks.Name, 'Create Safe Links policy')) {
|
|
New-SafeLinksPolicy -Name $Config.Defender.SafeLinks.Name `
|
|
-EnableSafeLinksForEmail $Config.Defender.SafeLinks.Enabled `
|
|
-TrackClicks $Config.Defender.SafeLinks.TrackClicks `
|
|
-AllowClickThrough $Config.Defender.SafeLinks.AllowClickThrough `
|
|
-ScanUrls $Config.Defender.SafeLinks.ScanUrls `
|
|
-EnableForInternalSenders $Config.Defender.SafeLinks.EnableForInternalSenders
|
|
# Create rule to apply it
|
|
New-SafeLinksRule -Name "$($Config.Defender.SafeLinks.Name)-Rule" -SafeLinksPolicy $Config.Defender.SafeLinks.Name -RecipientDomainIs (Get-AcceptedDomain).Name
|
|
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Fixed' -Message "Created Safe Links policy and rule"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Safe Attachments
|
|
Invoke-WithErrorHandling -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Action {
|
|
$policy = Get-SafeAttachmentPolicy -Identity $Config.Defender.SafeAttachments.Name -ErrorAction SilentlyContinue
|
|
if ($Mode -eq 'Assess') {
|
|
if ($policy) {
|
|
$pass = $policy.Enable -and ($policy.Action -eq 'Block')
|
|
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Safe Attachments exists. Enabled=$($policy.Enable) Action=$($policy.Action)" `
|
|
-Remediation "Set-SafeAttachmentPolicy -Identity '$($Config.Defender.SafeAttachments.Name)' -Enable `$true -Action Block"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Fail' `
|
|
-Message "Policy not found." -Remediation "New-SafeAttachmentPolicy -Name '$($Config.Defender.SafeAttachments.Name)' -Enable `$true -Action Block"
|
|
}
|
|
} else {
|
|
if ($policy) {
|
|
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeAttachments.Name, 'Update Safe Attachments policy')) {
|
|
Set-SafeAttachmentPolicy -Identity $Config.Defender.SafeAttachments.Name `
|
|
-Enable $Config.Defender.SafeAttachments.Enabled -Action $Config.Defender.SafeAttachments.Action
|
|
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Fixed' -Message "Updated Safe Attachments policy"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeAttachments.Name, 'Create Safe Attachments policy')) {
|
|
New-SafeAttachmentPolicy -Name $Config.Defender.SafeAttachments.Name `
|
|
-Enable $Config.Defender.SafeAttachments.Enabled -Action $Config.Defender.SafeAttachments.Action
|
|
New-SafeAttachmentRule -Name "$($Config.Defender.SafeAttachments.Name)-Rule" `
|
|
-SafeAttachmentPolicy $Config.Defender.SafeAttachments.Name -RecipientDomainIs (Get-AcceptedDomain).Name
|
|
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Fixed' -Message "Created Safe Attachments policy and rule"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Anti-Malware (Common Attachment Types Filter)
|
|
Invoke-WithErrorHandling -Workload 'Defender' -Control '2.1.2-AntiMalware' -Action {
|
|
$policy = Get-MalwareFilterPolicy -Identity $Config.Defender.AntiMalware.Name -ErrorAction SilentlyContinue
|
|
if ($Mode -eq 'Assess') {
|
|
if ($policy) {
|
|
$pass = $policy.EnableInternalSenderNotifications
|
|
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Anti-malware policy exists. InternalNotifications=$($policy.EnableInternalSenderNotifications)" `
|
|
-Remediation "Set-MalwareFilterPolicy -Identity '$($Config.Defender.AntiMalware.Name)' -EnableInternalSenderNotifications `$true"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Fail' `
|
|
-Message "Policy not found." -Remediation "New-MalwareFilterPolicy -Name '$($Config.Defender.AntiMalware.Name)' -EnableInternalSenderNotifications `$true"
|
|
}
|
|
} else {
|
|
if ($policy) {
|
|
if ($PSCmdlet.ShouldProcess($Config.Defender.AntiMalware.Name, 'Update anti-malware policy')) {
|
|
Set-MalwareFilterPolicy -Identity $Config.Defender.AntiMalware.Name -EnableInternalSenderNotifications $true
|
|
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Fixed' -Message "Updated anti-malware policy"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
if ($PSCmdlet.ShouldProcess($Config.Defender.AntiMalware.Name, 'Create anti-malware policy')) {
|
|
New-MalwareFilterPolicy -Name $Config.Defender.AntiMalware.Name -EnableInternalSenderNotifications $true
|
|
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Fixed' -Message "Created anti-malware policy"
|
|
} else {
|
|
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($Workloads -contains 'Exchange') {
|
|
Write-SectionHeader "Exchange Online"
|
|
|
|
# 6.2.1 - Block external forwarding
|
|
Invoke-WithErrorHandling -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Action {
|
|
$rule = Get-TransportRule | Where-Object { $_.Name -like '*CIS*forward*' -or $_.Name -eq 'CIS-Block-External-Forwarding' }
|
|
if ($Mode -eq 'Assess') {
|
|
if ($rule) {
|
|
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Pass' `
|
|
-Message "Transport rule exists: $($rule.Name)"
|
|
} else {
|
|
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Fail' `
|
|
-Message "No transport rule blocking external forwarding." `
|
|
-Remediation "New-TransportRule -Name 'CIS-Block-External-Forwarding' -FromScope 'InOrganization' -SentToScope 'NotInOrganization' -RejectMessageReasonText 'External forwarding is disabled'"
|
|
}
|
|
} else {
|
|
if (-not $rule) {
|
|
if ($PSCmdlet.ShouldProcess('Transport Rule', 'Create external forwarding block')) {
|
|
New-TransportRule -Name 'CIS-Block-External-Forwarding' `
|
|
-FromScope 'InOrganization' -SentToScope 'NotInOrganization' `
|
|
-RejectMessageReasonText 'External forwarding is disabled per security policy.' `
|
|
-RejectMessageEnhancedStatusCode '5.7.1'
|
|
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Fixed' -Message "Created transport rule"
|
|
} else {
|
|
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Pass' -Message "Rule already exists"
|
|
}
|
|
}
|
|
}
|
|
|
|
# 6.1.2 - Enable mailbox auditing
|
|
Invoke-WithErrorHandling -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Action {
|
|
$orgConfig = Get-OrganizationConfig
|
|
if ($Mode -eq 'Assess') {
|
|
$pass = $orgConfig.AuditDisabled -eq $false
|
|
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
|
|
-Message "AuditDisabled = $($orgConfig.AuditDisabled)" `
|
|
-Remediation "Set-OrganizationConfig -AuditDisabled `$false"
|
|
} else {
|
|
if ($orgConfig.AuditDisabled -ne $false) {
|
|
if ($PSCmdlet.ShouldProcess('Organization Config', 'Enable mailbox auditing')) {
|
|
Set-OrganizationConfig -AuditDisabled $false
|
|
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status 'Fixed' -Message "Enabled mailbox auditing"
|
|
} else {
|
|
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status 'Pass' -Message "Already enabled"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region SharePoint
|
|
if ($Workloads -contains 'SharePoint') {
|
|
Write-SectionHeader "SharePoint / OneDrive"
|
|
|
|
Invoke-WithErrorHandling -Workload 'SharePoint' -Control '7.x-ExternalSharing' -Action {
|
|
$tenant = Get-PnPTenant
|
|
|
|
# SharePoint external sharing
|
|
$spoSharing = $tenant.SharingCapability
|
|
$desiredSpo = $Config.SharePoint.SharePointExternalSharing
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' `
|
|
-Status $(if ($spoSharing -eq $desiredSpo) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Current: $spoSharing | Desired: $desiredSpo" `
|
|
-Remediation "Set-PnPTenant -SharingCapability $desiredSpo"
|
|
} else {
|
|
if ($spoSharing -ne $desiredSpo) {
|
|
if ($PSCmdlet.ShouldProcess('SharePoint Tenant', "Set sharing to $desiredSpo")) {
|
|
Set-PnPTenant -SharingCapability $desiredSpo
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status 'Fixed' -Message "Set to $desiredSpo"
|
|
} else {
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status 'Pass' -Message "Already set to $desiredSpo"
|
|
}
|
|
}
|
|
|
|
# OneDrive external sharing
|
|
$odbSharing = $tenant.OneDriveSharingCapability
|
|
$desiredOdb = $Config.SharePoint.OneDriveExternalSharing
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' `
|
|
-Status $(if ($odbSharing -eq $desiredOdb) { 'Pass' } else { 'Fail' }) `
|
|
-Message "Current: $odbSharing | Desired: $desiredOdb" `
|
|
-Remediation "Set-PnPTenant -OneDriveSharingCapability $desiredOdb"
|
|
} else {
|
|
if ($odbSharing -ne $desiredOdb) {
|
|
if ($PSCmdlet.ShouldProcess('OneDrive Tenant', "Set sharing to $desiredOdb")) {
|
|
Set-PnPTenant -OneDriveSharingCapability $desiredOdb
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status 'Fixed' -Message "Set to $desiredOdb"
|
|
} else {
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status 'Pass' -Message "Already set to $desiredOdb"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Teams
|
|
if ($Workloads -contains 'Teams') {
|
|
Write-SectionHeader "Microsoft Teams"
|
|
|
|
Invoke-WithErrorHandling -Workload 'Teams' -Control '8.x-AnonymousMeetings' -Action {
|
|
$config = Get-CsTeamsMeetingConfiguration
|
|
$anonJoin = (Get-CsTeamsMeetingPolicy -Identity Global).AllowAnonymousUsersToJoinMeeting
|
|
$anonStart = (Get-CsTeamsMeetingPolicy -Identity Global).AllowAnonymousUsersToStartMeeting
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' `
|
|
-Status $(if ($anonJoin -eq $Config.Teams.AllowAnonymousMeetingJoin) { 'Pass' } else { 'Fail' }) `
|
|
-Message "AllowAnonymousUsersToJoinMeeting = $anonJoin | Desired = $($Config.Teams.AllowAnonymousMeetingJoin)" `
|
|
-Remediation "Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting `$false"
|
|
} else {
|
|
if ($anonJoin -ne $Config.Teams.AllowAnonymousMeetingJoin) {
|
|
if ($PSCmdlet.ShouldProcess('Teams Global Policy', 'Restrict anonymous meeting join')) {
|
|
Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $Config.Teams.AllowAnonymousMeetingJoin
|
|
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status 'Fixed' -Message "Set to $($Config.Teams.AllowAnonymousMeetingJoin)"
|
|
} else {
|
|
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status 'Pass' -Message "Already set correctly"
|
|
}
|
|
}
|
|
}
|
|
|
|
Invoke-WithErrorHandling -Workload 'Teams' -Control '8.x-Federation' -Action {
|
|
$fedConfig = Get-CsTenantFederationConfiguration
|
|
|
|
if ($Mode -eq 'Assess') {
|
|
Add-Result -Workload 'Teams' -Control '8.x-Federation' `
|
|
-Status $(if ($fedConfig.AllowFederatedUsers -eq $Config.Teams.AllowFederatedUsers) { 'Pass' } else { 'Fail' }) `
|
|
-Message "AllowFederatedUsers = $($fedConfig.AllowFederatedUsers) | Desired = $($Config.Teams.AllowFederatedUsers)" `
|
|
-Remediation "Set-CsTenantFederationConfiguration -AllowFederatedUsers `$false"
|
|
} else {
|
|
if ($fedConfig.AllowFederatedUsers -ne $Config.Teams.AllowFederatedUsers) {
|
|
if ($PSCmdlet.ShouldProcess('Teams Federation', "Set AllowFederatedUsers to $($Config.Teams.AllowFederatedUsers)")) {
|
|
Set-CsTenantFederationConfiguration -AllowFederatedUsers $Config.Teams.AllowFederatedUsers
|
|
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status 'Fixed' -Message "Set to $($Config.Teams.AllowFederatedUsers)"
|
|
} else {
|
|
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status 'Skipped' -Message "WhatIf/Confirm declined"
|
|
}
|
|
} else {
|
|
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status 'Pass' -Message "Already set correctly"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Report
|
|
Write-SectionHeader "Summary Report"
|
|
|
|
$passCount = ($script:Results | Where-Object { $_.Status -eq 'Pass' }).Count
|
|
$failCount = ($script:Results | Where-Object { $_.Status -eq 'Fail' }).Count
|
|
$fixedCount = $script:ChangesMade
|
|
$skippedCount = $script:ChangesSkipped
|
|
$errorCount = $script:Errors
|
|
|
|
Write-Host "Mode: $Mode" -ForegroundColor $(if ($Mode -eq 'Assess') { 'Green' } else { 'Yellow' })
|
|
Write-Host "Workloads: $($Workloads -join ', ')"
|
|
Write-Host ""
|
|
Write-Host "Results:"
|
|
Write-Host " Pass: $passCount" -ForegroundColor Green
|
|
Write-Host " Fail: $failCount" -ForegroundColor Red
|
|
if ($Mode -eq 'Deploy') {
|
|
Write-Host " Fixed: $fixedCount" -ForegroundColor Cyan
|
|
Write-Host " Skipped: $skippedCount" -ForegroundColor Yellow
|
|
}
|
|
Write-Host " Errors: $errorCount" -ForegroundColor $(if ($errorCount -gt 0) { 'Red' } else { 'Gray' })
|
|
Write-Host ""
|
|
|
|
# Export results
|
|
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
|
$reportPath = "$PSScriptRoot\CISM365-RapidBaseline-Report_$Mode`_$timestamp.csv"
|
|
$script:Results | Export-Csv -Path $reportPath -NoTypeInformation -Force
|
|
Write-Host "Report saved to: $reportPath" -ForegroundColor Green
|
|
|
|
# Show failures if in Assess mode
|
|
if ($Mode -eq 'Assess' -and $failCount -gt 0) {
|
|
Write-Host "`nFailed checks:" -ForegroundColor Red
|
|
$script:Results | Where-Object { $_.Status -eq 'Fail' } | ForEach-Object {
|
|
Write-Host " [$($_.Workload)] $($_.Control): $($_.Message)" -ForegroundColor Red
|
|
if ($_.Remediation) { Write-Host " Remediation: $($_.Remediation)" -ForegroundColor DarkGray }
|
|
}
|
|
}
|
|
|
|
# Show errors
|
|
if ($errorCount -gt 0) {
|
|
Write-Host "`nErrors encountered:" -ForegroundColor Red
|
|
$script:Results | Where-Object { $_.Status -eq 'Error' } | ForEach-Object {
|
|
Write-Host " [$($_.Workload)] $($_.Control): $($_.Message)" -ForegroundColor Red
|
|
}
|
|
}
|
|
|
|
Write-Host "`nDone." -ForegroundColor Green
|
|
#endregion
|