Files
tomas.kracmar d3e0769799 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
2026-06-14 15:24:42 +02:00

1281 lines
64 KiB
PowerShell

#requires -Version 7.0
<#
.SYNOPSIS
Deploys a CIS M365 tenant-level baseline from a YAML manifest.
.DESCRIPTION
Reads a baseline YAML file that mirrors the OpenIntuneBaseline schema and
adds tenantConfig sections for Entra ID, Conditional Access, Defender,
Exchange, SharePoint, and Teams.
CONDITIONAL ACCESS SAFETY:
- All CA policies are created in report-only mode by default (global switch).
- The break-glass group is automatically excluded from every CA policy.
- You must explicitly pass -Apply when Mode is Deploy.
.EXAMPLE
# Assess without making any changes
./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath ./Baselines/mytenant-cisv7.yaml
.EXAMPLE
# Deploy after review
./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath ./Baselines/mytenant-cisv7.yaml -Mode Deploy -Apply -Verbose
.EXAMPLE
# Deploy only Conditional Access and Entra ID settings
./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath ./Baselines/mytenant-cisv7.yaml -Mode Deploy -Apply -Workloads EntraID,ConditionalAccess
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[string]$BaselinePath,
[Parameter()]
[string]$TenantId,
[Parameter()]
[ValidateSet('Assess','Deploy')]
[string]$Mode = 'Assess',
[Parameter()]
[ValidateSet('EntraID','ConditionalAccess','Defender','Exchange','SharePoint','Teams','AdminCenter','Purview','PowerBI')]
[string[]]$Workloads = @('EntraID','ConditionalAccess','Defender','Exchange','SharePoint','Teams','AdminCenter','Purview','PowerBI'),
[Parameter()]
[switch]$Apply,
[Parameter()]
[switch]$WhatIf,
[Parameter()]
[ValidateSet('AppOnly','Browser','DeviceCode')]
[string]$AuthMode = 'Browser',
[Parameter()]
[string]$AppId,
[Parameter()]
[string]$Secret,
[Parameter()]
[string]$Certificate
)
$ErrorActionPreference = 'Stop'
#region Helper Functions
function Test-YamlModule {
return [bool](Get-Module -ListAvailable -Name powershell-yaml)
}
function Install-YamlModule {
Write-Host "powershell-yaml module is required but not installed." -ForegroundColor Yellow
if (-not $WhatIf) {
$confirm = Read-Host "Install powershell-yaml from PSGallery now? [Y/n]"
if ($confirm -match "^\s*n") {
throw "powershell-yaml is required. Install it with: Install-Module powershell-yaml -Scope CurrentUser -Force"
}
Install-Module powershell-yaml -Scope CurrentUser -Force
Import-Module powershell-yaml -Force
}
}
function Resolve-RelativePath {
param([string]$Path, [string]$BasePath)
if ([System.IO.Path]::IsPathRooted($Path)) { return $Path }
$baseDir = Split-Path -Parent $BasePath
return Join-Path $baseDir $Path
}
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, Manual
[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++ }
}
}
function Invoke-ApplyMutation {
param([string]$Name, [hashtable]$Mutation)
if (-not $Mutation) { return $Name }
$search = $Mutation["search"]
$replace = $Mutation["replace"]
$prefix = $Mutation["prefix"]
if ($search -and $replace) {
$Name = $Name -replace $search, $replace
}
elseif ($prefix) {
if (-not $Name.StartsWith($prefix)) {
$Name = "$prefix$Name"
}
}
return $Name
}
# Common Entra admin role template IDs
$script:RoleTemplateMap = @{
"Global Administrator" = "62e90394-69f5-4237-9190-012177145e10"
"Privileged Role Administrator" = "e8611ab8-c189-46e8-94e1-60213ab1f814"
"Security Administrator" = "194ae4cb-b126-40b2-bd5b-6091b380977d"
"Exchange Administrator" = "29232cdf-9323-42fd-ade2-1d097af3e4de"
"SharePoint Administrator" = "f28a1f50-f6e7-4571-818b-6a12f2af6b6c"
"Conditional Access Administrator"= "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9"
"Application Administrator" = "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5d3"
"Cloud Application Administrator" = "158c047a-c907-4556-b7ef-446551a6b5f7"
"User Administrator" = "fe930be7-5e62-47db-91af-98c3a49a38b1"
"Helpdesk Administrator" = "729827e3-9c14-49f7-bb1b-9608f156bbb8"
"Billing Administrator" = "b0f54661-2d74-4c50-afa3-1ec803f12efe"
"Authentication Administrator" = "c4e39bd9-1100-46d3-8c65-fb160da0071f"
"Password Administrator" = "966707d0-3269-4727-9be2-8c3a10f19b9d"
"Global Reader" = "f2ef992c-3afb-46b9-b7cf-a127eeeb959e"
}
$script:Results = [System.Collections.Generic.List[object]]::new()
$script:ChangesMade = 0
$script:ChangesSkipped = 0
$script:Errors = 0
$script:GroupCache = @{}
$script:NamedLocationCache = @{}
$script:EffectiveWhatIf = $WhatIf.IsPresent -or ($Mode -eq 'Deploy' -and -not $Apply.IsPresent)
#endregion
#region Auth
Write-SectionHeader "Authentication"
# Microsoft Graph
$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','Group.ReadWrite.All'
)
Write-Host "Connecting to Microsoft Graph (mode: $AuthMode)..." -NoNewline
$connectParams = @{}
if ($TenantId) { $connectParams.TenantId = $TenantId }
switch ($AuthMode) {
'AppOnly' {
if (-not $AppId) { throw "AppId is required for AppOnly auth mode." }
if ($Secret) {
$secureSecret = ConvertTo-SecureString -String $Secret -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($AppId, $secureSecret)
$connectParams.ClientSecretCredential = $credential
}
elseif ($Certificate) {
$cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $Certificate -or $_.Subject -eq $Certificate } | Select-Object -First 1
if (-not $cert) { throw "Certificate not found: $Certificate" }
$connectParams.ClientCertificateCredential = $cert
}
else {
throw "Secret or Certificate is required for AppOnly auth mode."
}
Connect-MgGraph @connectParams -NoWelcome
}
'DeviceCode' {
Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -UseDeviceCode -NoWelcome
}
default { # Browser / Interactive
Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -NoWelcome
}
}
$context = Get-MgContext
Write-Host " OK ($($context.Account))" -ForegroundColor Green
# Exchange Online
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
# URL will be resolved from YAML later
Write-Host " deferred" -ForegroundColor Yellow
}
# Teams
if ($Workloads -contains 'Teams') {
Write-Host "Connecting to Microsoft Teams..." -NoNewline
Connect-MicrosoftTeams
Write-Host " OK" -ForegroundColor Green
}
#endregion
#region Load YAML
if (-not (Test-YamlModule)) { Install-YamlModule }
Import-Module powershell-yaml -Force
$baselinePathResolved = Resolve-Path $BaselinePath | Select-Object -ExpandProperty Path
if (-not (Test-Path $baselinePathResolved)) {
throw "Baseline file not found: $BaselinePath"
}
$baselineDir = Split-Path $baselinePathResolved -Parent
Write-Host "`nLoading baseline: $baselinePathResolved" -ForegroundColor Cyan
$yamlText = Get-Content $baselinePathResolved -Raw
$yamlRoot = ConvertFrom-Yaml -Yaml $yamlText
if (-not $yamlRoot -or -not $yamlRoot.ContainsKey("baseline")) {
throw "Invalid baseline YAML: missing 'baseline' root node."
}
$baseline = $yamlRoot["baseline"]
$globalMutation = $null
if ($baseline.ContainsKey("tenantMutation")) {
$globalMutation = $baseline["tenantMutation"]
}
$tenantConfig = $baseline.ContainsKey("tenantConfig") ? $baseline["tenantConfig"] : @{}
Write-Host "Baseline name : $($baseline["name"])" -ForegroundColor Cyan
Write-Host "Mode : $Mode" -ForegroundColor Cyan
Write-Host "Workloads : $($Workloads -join ', ')" -ForegroundColor Cyan
if ($script:EffectiveWhatIf) { Write-Host "*** DRY-RUN / WHATIF MODE ***" -ForegroundColor Magenta }
#endregion
#region Resolve / create groups
function Get-OrCreateGroup {
param([string]$DisplayName, [string]$MailNickname, [bool]$SecurityEnabled = $true)
if ($script:GroupCache.ContainsKey($DisplayName)) {
return $script:GroupCache[$DisplayName]
}
$existing = Get-MgGroup -Filter "displayName eq '$($DisplayName -replace "'","''")'" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($existing) {
Write-Host " Group exists: $DisplayName ($($existing.Id))" -ForegroundColor Green
$script:GroupCache[$DisplayName] = $existing.Id
return $existing.Id
}
if ($script:EffectiveWhatIf) {
Write-Host " [WHATIF] Would create group: $DisplayName" -ForegroundColor Magenta
$script:GroupCache[$DisplayName] = "WHATIF-$DisplayName"
return $script:GroupCache[$DisplayName]
}
Write-Host " Creating group: $DisplayName" -ForegroundColor Yellow
$newGrp = New-MgGroup -DisplayName $DisplayName -MailEnabled:$false -MailNickname $MailNickname -SecurityEnabled:$SecurityEnabled
$script:GroupCache[$DisplayName] = $newGrp.Id
Write-Host " Created: $($newGrp.Id)" -ForegroundColor Green
return $newGrp.Id
}
function Get-OrCreateNamedLocation {
param(
[string]$DisplayName,
[string]$Type,
[array]$CountriesAndRegions,
[bool]$IncludeUnknownCountriesAndRegions = $false,
[bool]$IsTrusted = $false,
[array]$IpRanges
)
if ($script:NamedLocationCache.ContainsKey($DisplayName)) {
return $script:NamedLocationCache[$DisplayName]
}
$existing = Get-MgIdentityConditionalAccessNamedLocation -Filter "displayName eq '$($DisplayName -replace "'","''")'" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($existing) {
Write-Host " Named location exists: $DisplayName ($($existing.Id))" -ForegroundColor Green
$script:NamedLocationCache[$DisplayName] = $existing.Id
return $existing.Id
}
if ($script:EffectiveWhatIf) {
Write-Host " [WHATIF] Would create named location: $DisplayName" -ForegroundColor Magenta
$script:NamedLocationCache[$DisplayName] = "WHATIF-$DisplayName"
return $script:NamedLocationCache[$DisplayName]
}
Write-Host " Creating named location: $DisplayName" -ForegroundColor Yellow
$body = @{ displayName = $DisplayName }
if ($Type -eq 'country') {
$body['@odata.type'] = '#microsoft.graph.countryNamedLocation'
$body['countriesAndRegions'] = $CountriesAndRegions
$body['includeUnknownCountriesAndRegions'] = $IncludeUnknownCountriesAndRegions
}
elseif ($Type -eq 'ip') {
$body['@odata.type'] = '#microsoft.graph.ipNamedLocation'
$body['isTrusted'] = $IsTrusted
$body['ipRanges'] = @()
foreach ($range in $IpRanges) {
$body['ipRanges'] += @{
'@odata.type' = '#microsoft.graph.iPv4CidrRange'
cidrAddress = $range
}
}
}
$newLoc = New-MgIdentityConditionalAccessNamedLocation -BodyParameter $body
$script:NamedLocationCache[$DisplayName] = $newLoc.Id
Write-Host " Created: $($newLoc.Id)" -ForegroundColor Green
return $newLoc.Id
}
if ($baseline.ContainsKey("groups") -and $baseline["groups"]) {
Write-SectionHeader "Resolving Groups"
foreach ($grpDef in $baseline["groups"]) {
$displayName = $grpDef["displayName"]
$mailNick = $grpDef["mailNickname"]
$secEnabled = if ($grpDef.ContainsKey("securityEnabled")) { [bool]$grpDef["securityEnabled"] } else { $true }
$null = Get-OrCreateGroup -DisplayName $displayName -MailNickname $mailNick -SecurityEnabled $secEnabled
}
}
#endregion
#region CA Policy Builder
function ConvertTo-CAPolicyPayload {
param([hashtable]$PolicyDef, [string]$BreakGlassGroupId, [hashtable]$Mutation)
$name = Invoke-ApplyMutation -Name $PolicyDef["name"] -Mutation $Mutation
$state = $PolicyDef["state"]
$description = $PolicyDef["description"]
# Build conditions
$conditions = @{}
# Applications
if ($PolicyDef["conditions"].ContainsKey("applications")) {
$appConditions = @{}
$appDef = $PolicyDef["conditions"]["applications"]
if ($appDef.ContainsKey("includeApplications")) { $appConditions["includeApplications"] = $appDef["includeApplications"] }
if ($appDef.ContainsKey("excludeApplications")) { $appConditions["excludeApplications"] = $appDef["excludeApplications"] }
if ($appDef.ContainsKey("includeUserActions")) { $appConditions["includeUserActions"] = $appDef["includeUserActions"] }
$conditions["applications"] = $appConditions
}
# Users
$userConditions = @{}
$userDef = $PolicyDef["conditions"]["users"]
if ($userDef) {
if ($userDef.ContainsKey("includeUsers")) { $userConditions["includeUsers"] = $userDef["includeUsers"] }
if ($userDef.ContainsKey("excludeUsers")) { $userConditions["excludeUsers"] = $userDef["excludeUsers"] }
if ($userDef.ContainsKey("includeGroups")) {
$userConditions["includeGroups"] = @()
foreach ($gn in $userDef["includeGroups"]) {
$gid = Get-OrCreateGroup -DisplayName $gn -MailNickname ($gn -replace "\s","")
if ($gid -notmatch "^WHATIF-") { $userConditions["includeGroups"] += $gid }
}
}
if ($userDef.ContainsKey("excludeGroups")) {
$userConditions["excludeGroups"] = @()
foreach ($gn in $userDef["excludeGroups"]) {
$gid = Get-OrCreateGroup -DisplayName $gn -MailNickname ($gn -replace "\s","")
if ($gid -notmatch "^WHATIF-") { $userConditions["excludeGroups"] += $gid }
}
}
# Auto-exclude break-glass group
if ($BreakGlassGroupId -and $BreakGlassGroupId -notmatch "^WHATIF-") {
if (-not $userConditions.ContainsKey("excludeGroups")) { $userConditions["excludeGroups"] = @() }
if ($userConditions["excludeGroups"] -notcontains $BreakGlassGroupId) {
$userConditions["excludeGroups"] += $BreakGlassGroupId
}
}
# Resolve roles
if ($userDef.ContainsKey("includeRoles")) {
$userConditions["includeRoles"] = @()
foreach ($roleName in $userDef["includeRoles"]) {
if ($script:RoleTemplateMap.ContainsKey($roleName)) {
$userConditions["includeRoles"] += $script:RoleTemplateMap[$roleName]
} else {
Write-Warning "Unknown role name '$roleName' in CA policy '$name'. Skipping."
}
}
}
if ($userDef.ContainsKey("excludeRoles")) {
$userConditions["excludeRoles"] = @()
foreach ($roleName in $userDef["excludeRoles"]) {
if ($script:RoleTemplateMap.ContainsKey($roleName)) {
$userConditions["excludeRoles"] += $script:RoleTemplateMap[$roleName]
}
}
}
# Guests / external users
if ($userDef.ContainsKey("includeGuestsOrExternalUsers")) {
$guestDef = $userDef["includeGuestsOrExternalUsers"]
$guestObj = @{}
if ($guestDef.ContainsKey("guestTypes")) { $guestObj["guestTypes"] = $guestDef["guestTypes"] }
if ($guestDef.ContainsKey("externalTenants")) {
$extDef = $guestDef["externalTenants"]
$guestObj["externalTenants"] = @{}
if ($extDef.ContainsKey("membershipKind")) { $guestObj["externalTenants"]["membershipKind"] = $extDef["membershipKind"] }
}
$userConditions["includeGuestsOrExternalUsers"] = $guestObj
}
if ($userDef.ContainsKey("excludeGuestsOrExternalUsers")) {
$guestDef = $userDef["excludeGuestsOrExternalUsers"]
$guestObj = @{}
if ($guestDef.ContainsKey("guestTypes")) { $guestObj["guestTypes"] = $guestDef["guestTypes"] }
if ($guestDef.ContainsKey("externalTenants")) {
$extDef = $guestDef["externalTenants"]
$guestObj["externalTenants"] = @{}
if ($extDef.ContainsKey("membershipKind")) { $guestObj["externalTenants"]["membershipKind"] = $extDef["membershipKind"] }
}
$userConditions["excludeGuestsOrExternalUsers"] = $guestObj
}
}
$conditions["users"] = $userConditions
# Client app types
if ($PolicyDef["conditions"].ContainsKey("clientAppTypes")) {
$conditions["clientAppTypes"] = $PolicyDef["conditions"]["clientAppTypes"]
}
# Sign-in risk
if ($PolicyDef["conditions"].ContainsKey("signInRiskLevels")) {
$conditions["signInRiskLevels"] = $PolicyDef["conditions"]["signInRiskLevels"]
}
# Locations
if ($PolicyDef["conditions"].ContainsKey("locations")) {
$locConditions = @{}
$locDef = $PolicyDef["conditions"]["locations"]
if ($locDef.ContainsKey("includeLocations")) {
$locConditions["includeLocations"] = @()
foreach ($loc in $locDef["includeLocations"]) {
if ($loc -eq 'All' -or $loc -eq 'AllTrusted' -or $loc -eq 'MfaTrusted') {
$locConditions["includeLocations"] += $loc
} elseif ($script:NamedLocationCache.ContainsKey($loc)) {
$locConditions["includeLocations"] += $script:NamedLocationCache[$loc]
} else {
Write-Warning "Named location '$loc' not found in cache for policy '$name'. Passing as-is."
$locConditions["includeLocations"] += $loc
}
}
}
if ($locDef.ContainsKey("excludeLocations")) {
$locConditions["excludeLocations"] = @()
foreach ($loc in $locDef["excludeLocations"]) {
if ($loc -eq 'All' -or $loc -eq 'AllTrusted' -or $loc -eq 'MfaTrusted') {
$locConditions["excludeLocations"] += $loc
} elseif ($script:NamedLocationCache.ContainsKey($loc)) {
$locConditions["excludeLocations"] += $script:NamedLocationCache[$loc]
} else {
Write-Warning "Named location '$loc' not found in cache for policy '$name'. Passing as-is."
$locConditions["excludeLocations"] += $loc
}
}
}
$conditions["locations"] = $locConditions
}
# Authentication flows (device code)
if ($PolicyDef["conditions"].ContainsKey("authenticationFlows")) {
$flowConditions = @{}
$flowDef = $PolicyDef["conditions"]["authenticationFlows"]
if ($flowDef.ContainsKey("deviceCodeFlow")) {
$flowConditions["deviceCodeFlow"] = @{
isEnabled = [bool]$flowDef["deviceCodeFlow"]["isEnabled"]
}
}
$conditions["authenticationFlows"] = $flowConditions
}
# Build grant controls
$grantControls = @{}
$grantDef = $PolicyDef["grantControls"]
if ($grantDef) {
$grantControls["operator"] = $grantDef["operator"]
$grantControls["builtInControls"] = $grantDef["builtInControls"]
if ($grantDef.ContainsKey("authenticationStrength")) {
$grantControls["authenticationStrength"] = @{
id = $grantDef["authenticationStrength"]["id"]
}
}
}
# Build session controls
$sessionControls = $null
if ($PolicyDef.ContainsKey("sessionControls")) {
$sessionControls = @{}
$sessDef = $PolicyDef["sessionControls"]
if ($sessDef.ContainsKey("signInFrequency")) {
$sessionControls["signInFrequency"] = @{
value = $sessDef["signInFrequency"]["value"]
type = $sessDef["signInFrequency"]["type"]
isEnabled = [bool]$sessDef["signInFrequency"]["isEnabled"]
}
}
if ($sessDef.ContainsKey("persistentBrowser")) {
$sessionControls["persistentBrowser"] = @{
mode = $sessDef["persistentBrowser"]["mode"]
isEnabled = [bool]$sessDef["persistentBrowser"]["isEnabled"]
}
}
}
$payload = @{
displayName = $name
state = $state
conditions = $conditions
grantControls = $grantControls
}
if ($description) { $payload["description"] = $description }
if ($sessionControls) { $payload["sessionControls"] = $sessionControls }
return $payload
}
#endregion
#region Process Tenant Config
function Invoke-WithErrorHandling {
param(
[string]$Workload,
[string]$Control,
[scriptblock]$Action,
[string]$Remediation = '',
[int]$MaxRetries = 3
)
$attempt = 0
do {
$attempt++
try {
& $Action
return
} catch {
$is429 = ($_.Exception.Response.StatusCode -eq 429)
if ($is429 -and $attempt -lt $MaxRetries) {
$retryAfterSec = 10
try {
$ra = $_.Exception.Response.Headers['Retry-After']
if ($ra) { $retryAfterSec = [int]$ra }
} catch { }
$sleepSec = [Math]::Min($retryAfterSec * [Math]::Pow(2, $attempt - 1), 120)
Write-Warning "[$Workload/$Control] 429 throttled. Retry $attempt/$MaxRetries after $sleepSec s."
Start-Sleep -Seconds $sleepSec
} else {
Add-Result -Workload $Workload -Control $Control -Status 'Error' -Message $_.Exception.Message -Remediation $Remediation
Write-Warning "[$Workload/$Control] ERROR: $_"
return
}
}
} while ($attempt -lt $MaxRetries)
}
# =====================================================================
# Admin Center
# =====================================================================
if ($Workloads -contains 'AdminCenter' -and $tenantConfig.ContainsKey('adminCenter')) {
Write-SectionHeader "M365 Admin Center"
$ac = $tenantConfig["adminCenter"]
# Password expiration
if ($ac.ContainsKey('passwordExpiration')) {
Invoke-WithErrorHandling -Workload 'AdminCenter' -Control '1.3.1-PasswordExpiration' -Action {
$org = Get-MgOrganization
$desired = if ($ac['passwordExpiration'] -eq 'NeverExpire') { 'DisablePasswordExpiration' } else { 'PasswordExpiration' }
$current = $org.PasswordPolicies
$pass = ($desired -eq 'DisablePasswordExpiration' -and $current -contains 'DisablePasswordExpiration')
if ($Mode -eq 'Assess') {
Add-Result -Workload 'AdminCenter' -Control '1.3.1-PasswordExpiration' `
-Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "Current: $current | Desired: $($ac['passwordExpiration'])" `
-Remediation "Update-MgOrganization -PasswordPolicies 'DisablePasswordExpiration'"
} else {
if (-not $pass -and $PSCmdlet.ShouldProcess($org.DisplayName, "Set password expiration to $($ac['passwordExpiration'])")) {
Update-MgOrganization -OrganizationId $org.Id -PasswordPolicies 'DisablePasswordExpiration'
Add-Result -Workload 'AdminCenter' -Control '1.3.1-PasswordExpiration' -Status 'Fixed' -Message "Set to NeverExpire"
} else {
Add-Result -Workload 'AdminCenter' -Control '1.3.1-PasswordExpiration' -Status $(if ($pass) { 'Pass' } else { 'Skipped' }) -Message $(if ($pass) { 'Already correct' } else { 'WhatIf/Confirm declined' })
}
}
}
}
}
# =====================================================================
# Entra ID
# =====================================================================
if ($Workloads -contains 'EntraID' -and $tenantConfig.ContainsKey('entraId')) {
Write-SectionHeader "Entra ID"
$entra = $tenantConfig["entraId"]
# Block tenant creation
if ($entra.ContainsKey('blockTenantCreation')) {
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' -Action {
$policy = Get-MgPolicyAuthorizationPolicy
$current = $policy.DefaultUserRolePermissions.AllowedToCreateTenants
$desired = -not [bool]$entra['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"
} else {
if ($current -ne $desired -and $PSCmdlet.ShouldProcess('Authorization Policy', "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 $(if ($current -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($current -eq $desired) { 'Already correct' } else { 'Declined' })
}
}
}
}
# Block user consent / app registration
if ($entra.ContainsKey('blockUserConsent')) {
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' -Action {
$policy = Get-MgPolicyAuthorizationPolicy
$current = $policy.DefaultUserRolePermissions.AllowedToCreateApps
$desired = -not [bool]$entra['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"
} else {
if ($current -ne $desired -and $PSCmdlet.ShouldProcess('Authorization Policy', "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 $(if ($current -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($current -eq $desired) { 'Already correct' } else { 'Declined' })
}
}
}
}
# Max devices per user
if ($entra.ContainsKey('maxDevicesPerUser')) {
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' -Action {
$regPolicy = Get-MgPolicyDeviceRegistrationPolicy
$current = $regPolicy.UserDeviceQuota
$desired = [int]$entra['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"
} else {
if ($current -ne $desired -and $PSCmdlet.ShouldProcess('Device Registration Policy', "Set max devices 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 $(if ($current -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($current -eq $desired) { 'Already correct' } else { 'Declined' })
}
}
}
}
# Banned passwords (supports inline list and/or external file)
$bannedPasswords = [System.Collections.Generic.List[string]]::new()
if ($entra.ContainsKey('bannedPasswords') -and $entra['bannedPasswords']) {
foreach ($p in $entra['bannedPasswords']) { $bannedPasswords.Add($p) }
}
if ($entra.ContainsKey('bannedPasswordsFile') -and $entra['bannedPasswordsFile']) {
$pwFile = $entra['bannedPasswordsFile']
if (-not [System.IO.Path]::IsPathRooted($pwFile)) {
$pwFile = Join-Path $baselineDir $pwFile
}
if (Test-Path $pwFile) {
$filePasswords = Get-Content $pwFile | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') }
foreach ($p in $filePasswords) { $bannedPasswords.Add($p) }
} else {
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Status 'Error' -Message "Banned passwords file not found: $pwFile"
}
}
if ($bannedPasswords.Count -gt 0) {
$bannedPasswords = $bannedPasswords | Select-Object -Unique
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Action {
$settings = Get-MgDirectorySetting | Where-Object { $_.DisplayName -eq 'Password Rule Settings' }
if (-not $settings) {
$template = Get-MgDirectorySettingTemplate | Where-Object { $_.DisplayName -eq 'Password Rule Settings' }
if (-not $template) {
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Status 'Error' -Message "Password Rule Settings template not found."
return
}
$settings = New-MgDirectorySetting -TemplateId $template.Id
}
$currentList = ($settings.Values | Where-Object { $_.Name -eq 'BannedPasswordList' }).Value
$desiredList = ($bannedPasswords -join ', ')
if ($Mode -eq 'Assess') {
$hasAll = ($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"
} else {
if ($PSCmdlet.ShouldProcess('Directory Setting', "Update banned password list")) {
Update-MgDirectorySetting -DirectorySettingId $settings.Id -Values @{
BannedPasswordList = $desiredList
EnableBannedPasswordCheck = $true
}
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 "Declined"
}
}
}
}
}
# =====================================================================
# Conditional Access
# =====================================================================
if ($Workloads -contains 'ConditionalAccess' -and $tenantConfig.ContainsKey('conditionalAccess')) {
Write-SectionHeader "Conditional Access"
$caConfig = $tenantConfig["conditionalAccess"]
$reportOnly = if ($caConfig.ContainsKey('reportOnly')) { [bool]$caConfig['reportOnly'] } else { $true }
$breakGlassGroupName = $caConfig['breakGlassGroup']
$breakGlassGroupId = $null
if ($breakGlassGroupName) {
$breakGlassGroupId = Get-OrCreateGroup -DisplayName $breakGlassGroupName -MailNickname ($breakGlassGroupName -replace "\s","")
}
Write-Host "CA Report-Only mode : $reportOnly" -ForegroundColor $(if ($reportOnly) { 'Yellow' } else { 'Green' })
Write-Host "Break-glass group : $breakGlassGroupName ($breakGlassGroupId)" -ForegroundColor Cyan
# Named locations
if ($caConfig.ContainsKey('namedLocations') -and $caConfig['namedLocations']) {
Write-Host "`nNamed locations:" -ForegroundColor Cyan
foreach ($nlDef in $caConfig['namedLocations']) {
$nlName = $nlDef['displayName']
$nlType = $nlDef['type']
$nlCountries = if ($nlDef.ContainsKey('countriesAndRegions')) { $nlDef['countriesAndRegions'] } else { $null }
$nlUnknown = if ($nlDef.ContainsKey('includeUnknownCountriesAndRegions')) { [bool]$nlDef['includeUnknownCountriesAndRegions'] } else { $false }
$nlTrusted = if ($nlDef.ContainsKey('isTrusted')) { [bool]$nlDef['isTrusted'] } else { $false }
$nlRanges = if ($nlDef.ContainsKey('ipRanges')) { $nlDef['ipRanges'] } else { $null }
$null = Get-OrCreateNamedLocation -DisplayName $nlName -Type $nlType -CountriesAndRegions $nlCountries -IncludeUnknownCountriesAndRegions:$nlUnknown -IsTrusted:$nlTrusted -IpRanges $nlRanges
}
}
$allCAPolicies = $null
try {
$allCAPolicies = Get-MgIdentityConditionalAccessPolicy -All
} catch {
Write-Warning "Could not enumerate existing CA policies: $_"
}
foreach ($caPolicyDef in $caConfig['policies']) {
$originalName = $caPolicyDef["name"]
$policyName = Invoke-ApplyMutation -Name $originalName -Mutation $globalMutation
$cisControl = $caPolicyDef["cisControl"]
Invoke-WithErrorHandling -Workload 'ConditionalAccess' -Control "$cisControl-$policyName" -Action {
# Override state if global reportOnly is true
$effectiveState = $caPolicyDef["state"]
if ($reportOnly -and $effectiveState -eq 'enabled') {
$effectiveState = 'enabledForReportingButNotEnforced'
}
# Build payload
$payload = ConvertTo-CAPolicyPayload -PolicyDef $caPolicyDef -BreakGlassGroupId $breakGlassGroupId -Mutation $globalMutation
$payload['state'] = $effectiveState
$payload['displayName'] = $policyName
# Check for existing policy
$existing = $null
if ($allCAPolicies) {
$existing = $allCAPolicies | Where-Object { $_.DisplayName -eq $policyName } | Select-Object -First 1
}
if ($Mode -eq 'Assess') {
if ($existing) {
$stateMatch = ($existing.State -eq $effectiveState)
Add-Result -Workload 'ConditionalAccess' -Control "$cisControl-$originalName" `
-Status $(if ($stateMatch) { 'Pass' } else { 'Fail' }) `
-Message "Policy exists. State: $($existing.State) | Desired: $effectiveState" `
-Remediation "Update-MgIdentityConditionalAccessPolicy -State '$effectiveState'"
} else {
Add-Result -Workload 'ConditionalAccess' -Control "$cisControl-$originalName" -Status 'Fail' `
-Message "Policy does not exist." `
-Remediation "Create via Entra admin center or Graph API"
}
} else {
if ($existing) {
if ($PSCmdlet.ShouldProcess($policyName, "Update CA policy state to $effectiveState")) {
$updatePayload = @{ state = $effectiveState }
if ($payload.ContainsKey('description')) { $updatePayload['description'] = $payload['description'] }
Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $existing.Id -BodyParameter $updatePayload
Add-Result -Workload 'ConditionalAccess' -Control "$cisControl-$originalName" -Status 'Fixed' -Message "Updated state to $effectiveState"
} else {
Add-Result -Workload 'ConditionalAccess' -Control "$cisControl-$originalName" -Status 'Skipped' -Message "Declined"
}
} else {
if ($PSCmdlet.ShouldProcess($policyName, "Create CA policy (STATE=$effectiveState)")) {
$newPolicy = New-MgIdentityConditionalAccessPolicy -BodyParameter $payload
$allCAPolicies = @($allCAPolicies) + $newPolicy
Add-Result -Workload 'ConditionalAccess' -Control "$cisControl-$originalName" -Status 'Fixed' `
-Message "Created policy '$policyName' in state '$effectiveState' (ID: $($newPolicy.Id))" `
-Remediation "Review in Entra admin center > Protection > Conditional Access"
} else {
Add-Result -Workload 'ConditionalAccess' -Control "$cisControl-$originalName" -Status 'Skipped' -Message "Declined"
}
}
}
}
}
}
# =====================================================================
# Defender
# =====================================================================
if ($Workloads -contains 'Defender' -and $tenantConfig.ContainsKey('defender')) {
Write-SectionHeader "Defender for Office 365"
$defender = $tenantConfig["defender"]
$script:DefenderAcceptedDomains = $null
# Safe Links
if ($defender.ContainsKey('safeLinks')) {
foreach ($sl in $defender['safeLinks']) {
$name = Invoke-ApplyMutation -Name $sl['name'] -Mutation $globalMutation
$cis = $sl['cisControl']
Invoke-WithErrorHandling -Workload 'Defender' -Control "$cis-$name" -Action {
$policy = Get-SafeLinksPolicy -Identity $name -ErrorAction SilentlyContinue
if ($Mode -eq 'Assess') {
if ($policy) {
$pass = $policy.EnableSafeLinksForEmail -and -not $policy.AllowClickThrough
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "SafeLinks exists. Email=$($policy.EnableSafeLinksForEmail) ClickThrough=$($policy.AllowClickThrough)"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fail' -Message "Policy not found."
}
} else {
if ($policy) {
if ($PSCmdlet.ShouldProcess($name, 'Update Safe Links')) {
Set-SafeLinksPolicy -Identity $name -EnableSafeLinksForEmail $sl['enabled'] -AllowClickThrough $sl['allowClickThrough'] -TrackClicks $sl['trackClicks'] -ScanUrls $sl['scanUrls'] -EnableForInternalSenders $sl['enableForInternalSenders']
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fixed' -Message "Updated Safe Links policy"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Skipped' -Message "Declined"
}
} else {
if ($PSCmdlet.ShouldProcess($name, 'Create Safe Links policy')) {
if (-not $script:DefenderAcceptedDomains)
{
$script:DefenderAcceptedDomains = @((Get-AcceptedDomain -ErrorAction Stop).Name)
if (-not $script:DefenderAcceptedDomains) { throw "No accepted domains returned from Exchange Online." }
}
New-SafeLinksPolicy -Name $name -EnableSafeLinksForEmail $sl['enabled'] -AllowClickThrough $sl['allowClickThrough'] -TrackClicks $sl['trackClicks'] -ScanUrls $sl['scanUrls'] -EnableForInternalSenders $sl['enableForInternalSenders']
New-SafeLinksRule -Name "$name-Rule" -SafeLinksPolicy $name -RecipientDomainIs $script:DefenderAcceptedDomains
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fixed' -Message "Created Safe Links policy + rule"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Skipped' -Message "Declined"
}
}
}
}
}
}
# Safe Attachments
if ($defender.ContainsKey('safeAttachments')) {
foreach ($sa in $defender['safeAttachments']) {
$name = Invoke-ApplyMutation -Name $sa['name'] -Mutation $globalMutation
$cis = $sa['cisControl']
Invoke-WithErrorHandling -Workload 'Defender' -Control "$cis-$name" -Action {
$policy = Get-SafeAttachmentPolicy -Identity $name -ErrorAction SilentlyContinue
if ($Mode -eq 'Assess') {
if ($policy) {
$pass = $policy.Enable -and ($policy.Action -eq $sa['action'])
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "SafeAttachments exists. Enabled=$($policy.Enable) Action=$($policy.Action)"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fail' -Message "Policy not found."
}
} else {
if ($policy) {
if ($PSCmdlet.ShouldProcess($name, 'Update Safe Attachments')) {
Set-SafeAttachmentPolicy -Identity $name -Enable $sa['enabled'] -Action $sa['action']
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fixed' -Message "Updated Safe Attachments policy"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Skipped' -Message "Declined"
}
} else {
if ($PSCmdlet.ShouldProcess($name, 'Create Safe Attachments policy')) {
if (-not $script:DefenderAcceptedDomains)
{
$script:DefenderAcceptedDomains = @((Get-AcceptedDomain -ErrorAction Stop).Name)
if (-not $script:DefenderAcceptedDomains) { throw "No accepted domains returned from Exchange Online." }
}
New-SafeAttachmentPolicy -Name $name -Enable $sa['enabled'] -Action $sa['action']
New-SafeAttachmentRule -Name "$name-Rule" -SafeAttachmentPolicy $name -RecipientDomainIs $script:DefenderAcceptedDomains
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fixed' -Message "Created Safe Attachments policy + rule"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Skipped' -Message "Declined"
}
}
}
}
}
}
# Anti-Malware
if ($defender.ContainsKey('antiMalware')) {
foreach ($am in $defender['antiMalware']) {
$name = Invoke-ApplyMutation -Name $am['name'] -Mutation $globalMutation
$cis = $am['cisControl']
Invoke-WithErrorHandling -Workload 'Defender' -Control "$cis-$name" -Action {
$policy = Get-MalwareFilterPolicy -Identity $name -ErrorAction SilentlyContinue
if ($Mode -eq 'Assess') {
if ($policy) {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status $(if ($policy.EnableInternalSenderAdminNotifications) { 'Pass' } else { 'Fail' }) `
-Message "AntiMalware exists. InternalNotifications=$($policy.EnableInternalSenderAdminNotifications)"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fail' -Message "Policy not found."
}
} else {
if ($policy) {
if ($PSCmdlet.ShouldProcess($name, 'Update anti-malware policy')) {
Set-MalwareFilterPolicy -Identity $name -EnableInternalSenderAdminNotifications $am['enableInternalNotifications']
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fixed' -Message "Updated anti-malware policy"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Skipped' -Message "Declined"
}
} else {
if ($PSCmdlet.ShouldProcess($name, 'Create anti-malware policy')) {
New-MalwareFilterPolicy -Name $name -EnableInternalSenderAdminNotifications $am['enableInternalNotifications']
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Fixed' -Message "Created anti-malware policy"
} else {
Add-Result -Workload 'Defender' -Control "$cis-$name" -Status 'Skipped' -Message "Declined"
}
}
}
}
}
}
}
# =====================================================================
# Exchange
# =====================================================================
if ($Workloads -contains 'Exchange' -and $tenantConfig.ContainsKey('exchange')) {
Write-SectionHeader "Exchange Online"
$ex = $tenantConfig["exchange"]
# Mailbox audit org-wide
if ($ex.ContainsKey('enableMailboxAuditOrgWide')) {
Invoke-WithErrorHandling -Workload 'Exchange' -Control '6.1.1-MailboxAudit' -Action {
$orgConfig = Get-OrganizationConfig
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Exchange' -Control '6.1.1-MailboxAudit' `
-Status $(if ($orgConfig.AuditDisabled -eq $false) { 'Pass' } else { 'Fail' }) `
-Message "AuditDisabled = $($orgConfig.AuditDisabled)"
} else {
if ($orgConfig.AuditDisabled -ne $false -and $PSCmdlet.ShouldProcess('Organization Config', 'Enable mailbox auditing')) {
Set-OrganizationConfig -AuditDisabled $false
Add-Result -Workload 'Exchange' -Control '6.1.1-MailboxAudit' -Status 'Fixed' -Message "Enabled org-wide mailbox auditing"
} else {
Add-Result -Workload 'Exchange' -Control '6.1.1-MailboxAudit' -Status $(if ($orgConfig.AuditDisabled -eq $false) { 'Pass' } else { 'Skipped' }) -Message $(if ($orgConfig.AuditDisabled -eq $false) { 'Already enabled' } else { 'Declined' })
}
}
}
}
# Block external forwarding
if ($ex.ContainsKey('blockExternalForwarding') -and $ex['blockExternalForwarding']) {
Invoke-WithErrorHandling -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Action {
$rule = Get-TransportRule | Where-Object { $_.Name -eq 'CIS-Block-External-Forwarding' }
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' `
-Status $(if ($rule) { 'Pass' } else { 'Fail' }) `
-Message $(if ($rule) { "Rule exists: $($rule.Name)" } else { "No blocking rule found." })
} else {
if (-not $rule -and $PSCmdlet.ShouldProcess('Transport Rules', '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 $(if ($rule) { 'Pass' } else { 'Skipped' }) -Message $(if ($rule) { 'Already exists' } else { 'Declined' })
}
}
}
}
}
# =====================================================================
# SharePoint
# =====================================================================
if ($Workloads -contains 'SharePoint' -and $tenantConfig.ContainsKey('sharePoint')) {
Write-SectionHeader "SharePoint / OneDrive"
$spo = $tenantConfig["sharePoint"]
# Connect now that we have the admin URL from YAML
$spoAdminUrl = $spo['adminUrl']
if (-not $spoAdminUrl) {
# Try to infer from tenant
$domains = Get-MgDomain
$defaultDomain = $domains | Where-Object { $_.IsInitial } | Select-Object -First 1
if ($defaultDomain) {
$spoAdminUrl = "https://$($defaultDomain.Id -replace '\.onmicrosoft\.com','')-admin.sharepoint.com"
}
}
if ($spoAdminUrl) {
Connect-PnPOnline -Url $spoAdminUrl -Interactive
Write-Host "Connected to SharePoint admin: $spoAdminUrl" -ForegroundColor Green
}
# SharePoint external sharing
if ($spo.ContainsKey('sharePointExternalSharing')) {
Invoke-WithErrorHandling -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Action {
$tenant = Get-PnPTenant
$desired = $spo['sharePointExternalSharing']
if ($Mode -eq 'Assess') {
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' `
-Status $(if ($tenant.SharingCapability -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "Current: $($tenant.SharingCapability) | Desired: $desired"
} else {
if ($tenant.SharingCapability -ne $desired -and $PSCmdlet.ShouldProcess('SharePoint Tenant', "Set sharing to $desired")) {
Set-PnPTenant -SharingCapability $desired
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status $(if ($tenant.SharingCapability -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($tenant.SharingCapability -eq $desired) { 'Already set' } else { 'Declined' })
}
}
}
}
# OneDrive external sharing
if ($spo.ContainsKey('oneDriveExternalSharing')) {
Invoke-WithErrorHandling -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Action {
$tenant = Get-PnPTenant
$desired = $spo['oneDriveExternalSharing']
if ($Mode -eq 'Assess') {
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' `
-Status $(if ($tenant.OneDriveSharingCapability -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "Current: $($tenant.OneDriveSharingCapability) | Desired: $desired"
} else {
if ($tenant.OneDriveSharingCapability -ne $desired -and $PSCmdlet.ShouldProcess('OneDrive Tenant', "Set sharing to $desired")) {
Set-PnPTenant -OneDriveSharingCapability $desired
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status $(if ($tenant.OneDriveSharingCapability -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($tenant.OneDriveSharingCapability -eq $desired) { 'Already set' } else { 'Declined' })
}
}
}
}
# Default sharing link type
if ($spo.ContainsKey('defaultSharingLinkType')) {
Invoke-WithErrorHandling -Workload 'SharePoint' -Control '7.x-DefaultSharingLinkType' -Action {
$tenant = Get-PnPTenant
$desired = $spo['defaultSharingLinkType']
if ($Mode -eq 'Assess') {
Add-Result -Workload 'SharePoint' -Control '7.x-DefaultSharingLinkType' `
-Status $(if ($tenant.DefaultSharingLinkType -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "Current: $($tenant.DefaultSharingLinkType) | Desired: $desired"
} else {
if ($tenant.DefaultSharingLinkType -ne $desired -and $PSCmdlet.ShouldProcess('SharePoint Tenant', "Set default link type to $desired")) {
Set-PnPTenant -DefaultSharingLinkType $desired
Add-Result -Workload 'SharePoint' -Control '7.x-DefaultSharingLinkType' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-DefaultSharingLinkType' -Status $(if ($tenant.DefaultSharingLinkType -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($tenant.DefaultSharingLinkType -eq $desired) { 'Already set' } else { 'Declined' })
}
}
}
}
# Deny custom scripts
if ($spo.ContainsKey('denyCustomScripts') -and $spo['denyCustomScripts']) {
Invoke-WithErrorHandling -Workload 'SharePoint' -Control '7.x-DenyCustomScripts' -Action {
$tenant = Get-PnPTenant
if ($Mode -eq 'Assess') {
Add-Result -Workload 'SharePoint' -Control '7.x-DenyCustomScripts' `
-Status $(if ($tenant.DenyAddAndCustomizePages -eq 1) { 'Pass' } else { 'Fail' }) `
-Message "DenyAddAndCustomizePages = $($tenant.DenyAddAndCustomizePages)"
} else {
if ($tenant.DenyAddAndCustomizePages -ne 1 -and $PSCmdlet.ShouldProcess('SharePoint Tenant', 'Deny custom scripts')) {
Set-PnPTenant -DenyAddAndCustomizePages 1
Add-Result -Workload 'SharePoint' -Control '7.x-DenyCustomScripts' -Status 'Fixed' -Message "Denied custom scripts"
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-DenyCustomScripts' -Status $(if ($tenant.DenyAddAndCustomizePages -eq 1) { 'Pass' } else { 'Skipped' }) -Message $(if ($tenant.DenyAddAndCustomizePages -eq 1) { 'Already denied' } else { 'Declined' })
}
}
}
}
}
# =====================================================================
# Teams
# =====================================================================
if ($Workloads -contains 'Teams' -and $tenantConfig.ContainsKey('teams')) {
Write-SectionHeader "Microsoft Teams"
$tm = $tenantConfig["teams"]
# Anonymous meeting join
if ($tm.ContainsKey('allowAnonymousUsersToJoinMeeting')) {
Invoke-WithErrorHandling -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Action {
$policy = Get-CsTeamsMeetingPolicy -Identity Global
$desired = [bool]$tm['allowAnonymousUsersToJoinMeeting']
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' `
-Status $(if ($policy.AllowAnonymousUsersToJoinMeeting -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "AllowAnonymousUsersToJoinMeeting = $($policy.AllowAnonymousUsersToJoinMeeting) | Desired = $desired"
} else {
if ($policy.AllowAnonymousUsersToJoinMeeting -ne $desired -and $PSCmdlet.ShouldProcess('Teams Global Meeting Policy', "Set anonymous join = $desired")) {
Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $desired
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status $(if ($policy.AllowAnonymousUsersToJoinMeeting -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($policy.AllowAnonymousUsersToJoinMeeting -eq $desired) { 'Already set' } else { 'Declined' })
}
}
}
}
# Anonymous meeting start
if ($tm.ContainsKey('allowAnonymousUsersToStartMeeting')) {
Invoke-WithErrorHandling -Workload 'Teams' -Control '8.x-AnonymousMeetingStart' -Action {
$policy = Get-CsTeamsMeetingPolicy -Identity Global
$desired = [bool]$tm['allowAnonymousUsersToStartMeeting']
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingStart' `
-Status $(if ($policy.AllowAnonymousUsersToStartMeeting -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "AllowAnonymousUsersToStartMeeting = $($policy.AllowAnonymousUsersToStartMeeting) | Desired = $desired"
} else {
if ($policy.AllowAnonymousUsersToStartMeeting -ne $desired -and $PSCmdlet.ShouldProcess('Teams Global Meeting Policy', "Set anonymous start = $desired")) {
Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToStartMeeting $desired
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingStart' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingStart' -Status $(if ($policy.AllowAnonymousUsersToStartMeeting -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($policy.AllowAnonymousUsersToStartMeeting -eq $desired) { 'Already set' } else { 'Declined' })
}
}
}
}
# Federation
if ($tm.ContainsKey('allowFederatedUsers')) {
Invoke-WithErrorHandling -Workload 'Teams' -Control '8.x-Federation' -Action {
$fedConfig = Get-CsTenantFederationConfiguration
$desired = [bool]$tm['allowFederatedUsers']
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Teams' -Control '8.x-Federation' `
-Status $(if ($fedConfig.AllowFederatedUsers -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "AllowFederatedUsers = $($fedConfig.AllowFederatedUsers) | Desired = $desired"
} else {
if ($fedConfig.AllowFederatedUsers -ne $desired -and $PSCmdlet.ShouldProcess('Teams Federation', "Set AllowFederatedUsers = $desired")) {
Set-CsTenantFederationConfiguration -AllowFederatedUsers $desired
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status $(if ($fedConfig.AllowFederatedUsers -eq $desired) { 'Pass' } else { 'Skipped' }) -Message $(if ($fedConfig.AllowFederatedUsers -eq $desired) { 'Already set' } else { 'Declined' })
}
}
}
}
}
# =====================================================================
# Purview
# =====================================================================
if ($Workloads -contains 'Purview' -and $tenantConfig.ContainsKey('purview')) {
Write-SectionHeader "Microsoft Purview"
$pv = $tenantConfig["purview"]
# Audit log search
if ($pv.ContainsKey('enableAuditLogSearch')) {
Invoke-WithErrorHandling -Workload 'Purview' -Control '3.1.1-AuditLogSearch' -Action {
$org = Get-MgOrganization
$desired = [bool]$pv['enableAuditLogSearch']
$current = $org.AuditLogEnabled
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Purview' -Control '3.1.1-AuditLogSearch' `
-Status $(if ($current -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "AuditLogEnabled = $current | Desired = $desired"
} else {
if ($PSCmdlet.ShouldProcess('Organization', "Set AuditLogEnabled = $desired")) {
# Audit log is typically enabled via Exchange Online, not directly via Graph org
Add-Result -Workload 'Purview' -Control '3.1.1-AuditLogSearch' -Status 'Skipped' -Message "Enable via Exchange Admin Center or Set-AdminAuditLogConfig"
} else {
Add-Result -Workload 'Purview' -Control '3.1.1-AuditLogSearch' -Status 'Skipped' -Message "Declined"
}
}
}
}
# DLP policies (draft — warn if uncommented)
if ($pv.ContainsKey('dlpPolicies')) {
Write-Host " NOTE: DLP policies found in baseline but require tenant-specific customization." -ForegroundColor Yellow
Write-Host " Review and edit the policies in the YAML before deploying." -ForegroundColor DarkGray
Add-Result -Workload 'Purview' -Control '3.2.x-DLPPolicies' -Status 'Manual' -Message "Draft DLP policies require customization before deployment"
}
# Sensitivity labels (draft — warn if uncommented)
if ($pv.ContainsKey('sensitivityLabels') -or $pv.ContainsKey('sensitivityLabelPolicies')) {
Write-Host " NOTE: Sensitivity labels found in baseline but require tenant-specific customization." -ForegroundColor Yellow
Write-Host " Review and edit the labels in the YAML before deploying." -ForegroundColor DarkGray
Add-Result -Workload 'Purview' -Control '3.3.x-SensitivityLabels' -Status 'Manual' -Message "Draft sensitivity labels require customization before deployment"
}
}
# =====================================================================
# Power BI
# =====================================================================
if ($Workloads -contains 'PowerBI' -and $tenantConfig.ContainsKey('powerBI')) {
Write-SectionHeader "Power BI"
$pbi = $tenantConfig["powerBI"]
Write-Host " NOTE: Power BI tenant settings are not yet auto-deployed." -ForegroundColor Yellow
Write-Host " Review the YAML and configure via Power BI Admin portal or Microsoft365DSC." -ForegroundColor DarkGray
Add-Result -Workload 'PowerBI' -Control '9.x-PowerBI' -Status 'Manual' -Message "Power BI settings require manual deployment via Admin portal or Microsoft365DSC"
}
#endregion
#region Summary Report
Write-SectionHeader "Summary Report"
$passCount = ($script:Results | Where-Object { $_.Status -eq 'Pass' }).Count
$failCount = ($script:Results | Where-Object { $_.Status -eq 'Fail' }).Count
$manualCount = ($script:Results | Where-Object { $_.Status -eq 'Manual' }).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 ', ')"
if ($script:EffectiveWhatIf) { Write-Host "*** DRY-RUN / WHATIF MODE ***" -ForegroundColor Magenta }
Write-Host ""
Write-Host "Results:"
Write-Host " Pass: $passCount" -ForegroundColor Green
Write-Host " Fail: $failCount" -ForegroundColor Red
Write-Host " Manual: $manualCount" -ForegroundColor Yellow
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 = Join-Path $PSScriptRoot "CISM365-Baseline-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 }
}
}
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