release: v4.1.0 — restructure entry points, add CIS baselines, reporting tools and fzf hints

- 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
This commit is contained in:
2026-06-14 15:24:42 +02:00
parent e333af978c
commit d3e0769799
30 changed files with 8711 additions and 175 deletions
@@ -0,0 +1,234 @@
@{
# =====================================================================
# CIS M365 Rapid Baseline Configuration
# =====================================================================
# This file defines the desired state for a new/greenfield tenant.
# Edit values before running Deploy-CISM365RapidBaseline.ps1.
#
# IMPORTANT: This baseline is designed for NEW or NEWLY-ACQUIRED tenants.
# On an established tenant, some changes may impact users.
# =====================================================================
Tenant = @{
# Your tenant's initial .onmicrosoft.com domain
TenantDomain = 'contoso.onmicrosoft.com'
# SharePoint admin center URL
SharePointAdminUrl = 'https://contoso-admin.sharepoint.com'
# License profile: E3 | E5 | E3+P2
# Determines whether P2-only features (Identity Protection, PIM) are configured
LicenseProfile = 'E3'
}
# =====================================================================
# Section 5: Entra ID (Identity)
# =====================================================================
EntraID = @{
# 1.3.1 - Password expiration policy
PasswordExpiration = 'NeverExpire' # NeverExpire | 90Days | 180Days
# 5.2.3.2 - Custom banned password list
BannedPasswords = @('Contoso', 'Contoso1', 'Password', 'Welcome')
# 5.1.2.3 - Restrict non-admin users from creating tenants
BlockTenantCreation = $true
# 5.1.2.6 - Disable LinkedIn account connections
DisableLinkedIn = $true
# 5.1.2.2 - Disallow third-party integrated applications (user consent)
# Note: Set to $true for strict CIS compliance. May break some SaaS integrations.
BlockUserConsent = $true
# 5.1.4.2 - Maximum devices per user
MaxDevicesPerUser = 5
# 5.1.4.3 - Do not add GA role as local admin during Entra join
GALocalAdminDisabled = $true
# 5.2.3.1 - Microsoft Authenticator: protect against MFA fatigue
MFAFatigueProtection = $true
# Emergency access accounts (break-glass) - used for CA policy exclusions
BreakGlassAccounts = @(
'breakglass1@contoso.onmicrosoft.com'
'breakglass2@contoso.onmicrosoft.com'
)
}
# =====================================================================
# Section 5.2.2: Conditional Access Policies
# =====================================================================
ConditionalAccess = @(
@{
Name = 'CIS-Block-Legacy-Auth'
Description = 'CIS 5.2.2.3 - Block legacy authentication protocols'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
ClientAppTypes = @('exchangeActiveSync', 'other')
}
GrantControls = @{
BuiltInControls = @('block')
Operator = 'OR'
}
}
@{
Name = 'CIS-Require-MFA-Admins'
Description = 'CIS 5.2.2.1 - Require MFA for all users in administrative roles'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeRoles = @('62e90394-69f5-4237-9190-012177145e10') } # Exclude Global Admin if using PIM
}
GrantControls = @{
BuiltInControls = @('mfa')
Operator = 'OR'
}
}
@{
Name = 'CIS-Require-MFA-All-Users'
Description = 'CIS 5.2.2.2 - Require MFA for all users'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
Locations = @{ IncludeLocations = @('AllTrusted') } # Requires named locations
}
GrantControls = @{
BuiltInControls = @('mfa')
Operator = 'OR'
}
}
@{
Name = 'CIS-Block-Device-Code-Flow'
Description = 'CIS 5.2.2.12 - Block device code sign-in flow'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
AuthenticationFlows = @{ IncludeAuthenticationFlows = @('deviceCode') }
}
GrantControls = @{
BuiltInControls = @('block')
Operator = 'OR'
}
}
@{
Name = 'CIS-Block-High-Risk-SignIns'
Description = 'CIS 5.2.2.8 - Block sign-ins with medium/high risk (requires P2)'
Enabled = $true
State = 'enabledForReportingButNotEnforced' # Set to 'enabled' after validation
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
SignInRiskLevels = @('high', 'medium')
}
GrantControls = @{
BuiltInControls = @('block')
Operator = 'OR'
}
}
)
# =====================================================================
# Section 2: Microsoft Defender for Office 365
# =====================================================================
Defender = @{
# 2.1.1 - Safe Links for Office Applications
SafeLinks = @{
Name = 'CIS-SafeLinks-Default'
Enabled = $true
TrackClicks = $true
AllowClickThrough = $false
ScanUrls = $true
EnableForInternalSenders = $true
}
# 2.1.4 - Safe Attachments
SafeAttachments = @{
Name = 'CIS-SafeAttachments-Default'
Enabled = $true
Action = 'Block' # Block | DynamicDelivery | Monitor
QuarantineMessages = $true
}
# 2.1.2 - Common Attachment Types Filter (built into anti-malware)
AntiMalware = @{
Name = 'CIS-AntiMalware-Default'
Enabled = $true
EnableInternalSenderNotifications = $true
FileTypes = @('ace', 'ani', 'app', 'docm', 'exe', 'iso', 'jar', 'jnlp', 'msi', 'php', 'ps1', 'scr', 'vbs', 'wsf')
}
# Anti-Phish baseline
AntiPhish = @{
Name = 'CIS-AntiPhish-Default'
Enabled = $true
EnableMailboxIntelligence = $true
EnableSpoofIntelligence = $true
MailboxIntelligenceProtectionAction = 'Quarantine'
TargetedUserProtectionAction = 'Quarantine'
TargetedDomainProtectionAction = 'Quarantine'
}
}
# =====================================================================
# Section 6: Exchange Online
# =====================================================================
Exchange = @{
# 6.2.1 - Block all forms of external mail forwarding
BlockExternalForwarding = $true
# 6.1.2 - Enable mailbox auditing organization-wide
EnableMailboxAudit = $true
# 6.2.3 - Identify email from external senders (external sender banner)
EnableExternalSenderBanner = $true
# Transport rule: prepend external email warning
ExternalEmailWarning = $true
}
# =====================================================================
# Section 7: SharePoint / OneDrive
# =====================================================================
SharePoint = @{
# 7.x - Default sharing link type
# Options: Direct, Internal, AnonymousAccess
DefaultSharingLinkType = 'Direct' # Most restrictive = Direct (specific people only)
# 7.x - External sharing for SharePoint
SharePointExternalSharing = 'Disabled' # Disabled | ExistingExternalUserSharingOnly | ExternalUserSharingOnly | Anyone
# 7.x - External sharing for OneDrive
OneDriveExternalSharing = 'Disabled' # Disabled | ExistingExternalUserSharingOnly | ExternalUserSharingOnly | Anyone
# Guest access expiration (days)
GuestAccessExpirationDays = 30
}
# =====================================================================
# Section 8: Microsoft Teams
# =====================================================================
Teams = @{
# 8.x - Allow anonymous users to join meetings
AllowAnonymousMeetingJoin = $false
# 8.x - Allow anonymous users to start meetings
AllowAnonymousMeetingStart = $false
# 8.x - Teams email integration
EnableEmailIntegration = $false
# Federation / external access
AllowFederatedUsers = $false
AllowTeamsConsumer = $false
}
}
@@ -0,0 +1,699 @@
<#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
+172
View File
@@ -0,0 +1,172 @@
# CIS M365 Rapid Baseline
> **Goal:** Take a new or newly-acquired tenant from zero to ~80% CIS M365 Foundations compliance in hours, not weeks.
Your existing `IntuneManagement` toolkit already handles **Section 4 (Intune)** of the CIS benchmark. This complements it with the tenant-level workloads: Entra ID, Conditional Access, Defender, Exchange, SharePoint, and Teams.
---
## The Reality Check
There is no single "Install-CIS-M365" command. The benchmark has **140 controls** across **9 sections**, and many are:
- **Assessment-only** (e.g., "Ensure 24 global admins exist" — a script can't decide who your admins should be)
- **License-dependent** (Identity Protection risk policies require Entra ID P2)
- **Tenant-specific** (Conditional Access exclusions, emergency access accounts, accepted domains)
**This baseline automates the ~40 highest-impact controls that are safe to script on a greenfield tenant.** The rest require human judgment.
---
## Prerequisites
```powershell
# PowerShell 7+ is strongly recommended
$PSVersionTable.PSVersion
# Install dependencies
Install-Module Microsoft.Graph -Scope CurrentUser -Force
Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force
Install-Module PnP.PowerShell -Scope CurrentUser -Force
Install-Module MicrosoftTeams -Scope CurrentUser -Force
```
**Permissions required:**
- Global Administrator (to create policies and grant consent)
- Or: combination of Privileged Role Administrator + Exchange Administrator + SharePoint Administrator + Teams Administrator
---
## The Fastest Path (Recommended Workflow)
### Step 0: Customize the config
Edit `CISM365-RapidBaseline.psd1`:
- Set your `TenantDomain` and `SharePointAdminUrl`
- Add your **break-glass emergency access accounts** to `BreakGlassAccounts`
- Adjust `ConditionalAccess` policies to reference your actual admin roles/groups
- Review `SharePointExternalSharing``Disabled` is most secure but may break planned collaboration
- Review `BlockUserConsent``true` is CIS-compliant but may break SaaS integrations
### Step 1: Assess (read-only)
```powershell
cd Baselines/M365-CIS-Rapid
# Default: assess everything, make zero changes
./Deploy-CISM365RapidBaseline.ps1
```
Review the CSV report. It tells you exactly what's wrong and how to fix it.
### Step 2: Deploy the easy wins
```powershell
# Deploy with WhatIf first (simulates changes without applying)
./Deploy-CISM365RapidBaseline.ps1 -Mode Deploy -WhatIf
# If satisfied, apply for real
./Deploy-CISM365RapidBaseline.ps1 -Mode Deploy -Apply -Verbose
```
### Step 3: Create Conditional Access policies manually
**This script intentionally does NOT auto-create Conditional Access policies.** CA misconfiguration can lock everyone out of the tenant, including you.
Use the assessment output as a checklist and create them in the Entra admin center:
1. **CIS-Block-Legacy-Auth** — Block all legacy auth protocols
2. **CIS-Require-MFA-Admins** — Require MFA for all admin roles
3. **CIS-Require-MFA-All-Users** — Require MFA for all users
4. **CIS-Block-Device-Code-Flow** — Block device code authentication
5. **CIS-Block-High-Risk-SignIns** — Block medium/high risk sign-ins (requires P2)
> **Pro tip:** Set new CA policies to `enabledForReportingButNotEnforced` for 24 hours before flipping to `enabled`. This lets you verify they don't block legitimate access.
### Step 4: Run a full CIS assessment
```powershell
# Install the comprehensive CIS assessment module
Install-Module CIS-M365-Benchmark -Scope CurrentUser -Force
Connect-CISM365Benchmark
Invoke-CISM365Benchmark -ProfileLevel L1 -ExcludeSections Intune
```
This checks all 140 controls and produces an HTML report with remediation steps for the remaining gaps.
### Step 5: Ongoing governance (optional but recommended)
For drift detection and continuous enforcement, introduce **Microsoft365DSC**:
```powershell
Install-Module Microsoft365DSC -Force
Update-M365DSCDependencies
# Export your now-hardened tenant as code
Export-M365DSCConfiguration -Workloads @("AAD","EXO","SPO","Teams") -Path ./m365-golden
```
Store that golden configuration in Git and run it through a pipeline weekly.
---
## What This Script Covers
| CIS Section | Controls Automated | Notes |
|-------------|-------------------|-------|
| **5.1** M365 Admin Center | Password expiration, tenant creation block, device quota, user consent | |
| **5.2.2** Conditional Access | Assessment only (safe by design) | Manual creation recommended |
| **5.2.3** Auth Methods | Banned password list | |
| **2.1** Defender | Safe Links, Safe Attachments, Anti-malware | Creates policy + rule |
| **6.1/6.2** Exchange | Mailbox auditing, external forwarding block | Transport rule |
| **7.x** SharePoint | External sharing restrictions | SPO + OneDrive |
| **8.x** Teams | Anonymous meeting restrictions, federation | Global policy |
**What it does NOT cover (requires human judgment):**
- Admin role assignments (how many GAs, who are they)
- Emergency access accounts (you must create these first)
- PIM configuration (requires P2, approval workflows)
- DMARC/DKIM/SPF records (DNS-level, not tenant-level)
- DLP policies (business-specific)
- Sensitivity labels (business-specific)
- Intune device policies (use your existing toolkit)
---
## Safety Features
- **`-Mode Assess` is the default.** Nothing changes unless you explicitly say `-Mode Deploy -Apply`.
- **`-WhatIf` is supported.** Use it to preview every change.
- **Break-glass exclusion.** The CA assessment template references `BreakGlassAccounts` — make sure these exist and are excluded from MFA/Compliance policies before enabling them.
- **Modular workloads.** Use `-Workloads` to target only one area at a time.
---
## Newly-Acquired vs. New Tenant
| Scenario | Approach |
|----------|----------|
| **Brand new tenant** (no users yet) | Run `-Mode Deploy -Apply` freely. Then create CA policies. |
| **Newly-acquired tenant** (has users, mailboxes, existing config) | Run `-Mode Assess` first. Review EVERY failed control for business impact before deploying. Some changes (e.g., disabling external sharing, blocking user consent) can break existing workflows. |
---
## Alternatives Considered
| Tool | Best For | Why We Didn't Use It As Primary |
|------|----------|--------------------------------|
| **Microsoft365DSC** | Long-term governance, drift detection | Learning curve is too high for "as fast as possible"; better introduced after initial hardening |
| **CISA ScubaGear** | Federal compliance, audit evidence | Read-only assessment; no deployment capability |
| **CIS-M365-Benchmark** | Comprehensive 140-control assessment | Read-only; excellent for gap analysis after rapid deployment |
| **Maester** | CI/CD testing, continuous validation | Read-only; great for pipelines, not initial deployment |
| **CoreView / Inforcer** | MSP multi-tenant deployment | Commercial; not applicable if you want open-source/scripted |
---
## Next Steps
1. Customize `CISM365-RapidBaseline.psd1`
2. Run assess mode
3. Deploy the easy wins
4. Create CA policies manually with reporting mode
5. Run `CIS-M365-Benchmark` for the remaining gaps
6. Introduce `Microsoft365DSC` for ongoing governance