d3e0769799
- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root; Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/ - Add AGENTS.md with project architecture, entry points, and security notes - Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts - Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport, Export-ObjectInventoryReport) and CA wizard helpers - Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath, and optimized group loading - Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry - Update Extensions for Settings Catalog definition auto-export - Update README with v4.1.0, new entry points and script catalog - Bump VERSION to 4.1.0 - Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports, Settings.json and IntuneManagement.log
1281 lines
64 KiB
PowerShell
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
|