feat(reporting): expand settings report and add CA documentation

- Export-SettingsReport.py: add coverage for Compliance V2, Endpoint
  Security, Device Management Intents, ADMX, scripts (base64 decoded),
  App Protection/Configuration, updates, enrollment, Autopilot, W365,
  filters, and more; add OMA-URI and customSettings expansion
- Start-IntuneToolkit.ps1: add menu item 19 for Conditional Access
  documentation
- Add Invoke-ConditionalAccessDocumentation.ps1 for CA policy docs
  (CSV/Excel)
- .gitignore: exclude CA documentation CSV/XLSX outputs
- CHANGELOG: document the new reporting capabilities
This commit is contained in:
2026-06-29 15:12:37 +02:00
parent 122aa2d4e3
commit 90209a7ec4
5 changed files with 1114 additions and 34 deletions
+2
View File
@@ -20,6 +20,8 @@ IntuneManagement.log
Exporting */
*.backup/
*.backup
Scripts/ConditionalAccessDocumentation.csv
Scripts/ConditionalAccessDocumentation.xlsx
# Graph metadata cache (now stored in the platform-specific data folder)
GraphMetaData.xml
+21
View File
@@ -1,5 +1,26 @@
# macOS Intune Toolkit Changelog
## 2026-06-22 — Expanded settings report coverage and Conditional Access documentation
### Added
- **`Scripts/Export-SettingsReport.py`**
- Greatly expanded coverage: Settings Catalog, Compliance Policies V2, Endpoint Security, Device Management Intents, Administrative Templates, Device Configuration (with OMA-URI expansion), Compliance Policies V1, PowerShell/Shell/Compliance/Health scripts (base64-decoded previews), App Protection, App Configuration, Update Policies, Enrollment restrictions, Autopilot, W365, Filters, and more.
- Added dedicated processors for intent-style settings (`_Settings.json` companion files) and ADMX definition values.
- Added base64 decoding for script content with a configurable preview length.
- **`Scripts/Invoke-ConditionalAccessDocumentation.ps1`**
- New menu-driven Conditional Access policy documentation script (menu item **19** in `Start-IntuneToolkit.ps1`).
- Exports CA policies to CSV and optionally Excel, resolving object IDs to display names.
- **`Start-IntuneToolkit.ps1`**
- Added menu item **19. Document Conditional Access policies**.
### Fixed
- **`.gitignore`**
- Ignore `Scripts/ConditionalAccessDocumentation.csv` and `.xlsx` output files so tenant-specific documentation is not committed by accident.
---
## 2026-06-14 — Auto-export Settings Catalog definitions for report resolution
### Added
+349 -34
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env python3
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
Covers Settings Catalog policies (human-readable names resolved from
configurationSettings.json when present) and flat Device Configuration /
Compliance Policy objects.
Covers:
- Settings Catalog + Compliance Policies V2 (settingInstance structure)
- Endpoint Security / Device Management Intents (companion _Settings.json,
old intent API with definitionId + value/valueJson)
- Administrative Templates (companion _Settings.json, definitionValues)
- Device Configuration + Compliance Policies V1 (flat, with OMA-URI expansion)
- Scripts: PowerShell, Shell, Custom Attributes, Health Scripts
(scriptContent / detectionScriptContent / remediationScriptContent decoded from base64)
- App Protection, App Configuration App/Device (flat + customSettings expansion)
- Update, Enrollment, Autopilot, W365, Filters, and other flat types
Human-readable setting names resolved from configurationSettings.json when present.
Output columns: Policy, Platform, Setting, Value
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
@@ -13,6 +22,7 @@ With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
from __future__ import annotations
import argparse
import base64
import csv
import json
import re
@@ -20,8 +30,8 @@ from pathlib import Path
from typing import Any, Optional
OUTPUT_FILE = "settings-report.csv"
BASE_FIELDNAMES = ["Policy", "Platform", "Setting", "Value"]
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
_PLATFORM_LABELS = {
"windows10": "Windows 10/11",
@@ -34,7 +44,6 @@ _PLATFORM_LABELS = {
"linux": "Linux",
"chromeOS": "Chrome OS",
}
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
_SKIP_KEYS = {
"@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version",
@@ -44,8 +53,23 @@ _SKIP_KEYS = {
"deviceManagementApplicabilityRuleOsVersion", "deviceManagementApplicabilityRuleDeviceMode",
"supportsScopeTags", "settingCount", "priorityMetaData", "creationSource",
"templateReference", "name", "platforms", "technologies",
# settings arrays are handled by dedicated processors; avoid JSON blobs in flat categories
"settings",
}
# Expanded by dedicated helpers; excluded from generic flat key loop
_SPECIAL_KEYS = {"omaSettings", "customSettings"}
# Keys with base64-encoded text content
_B64_TEXT_KEYS = {"scriptContent", "detectionScriptContent", "remediationScriptContent", "payloadJson"}
_SCRIPT_PREVIEW_CHARS = 300
# ---------------------------------------------------------------------------
# Args
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--root", required=True,
@@ -124,19 +148,13 @@ def _platform_from_odata(odata_type: str) -> str:
def _extract_platform(policy: dict, category: str = "") -> str:
"""Best-effort platform/OS extraction for Settings Catalog and legacy policies."""
# Settings Catalog direct fields
platforms = policy.get("platforms")
if platforms:
return _normalize_platforms(platforms)
# Legacy policies sometimes expose platform/platformType directly
for key in ("platform", "platformType"):
val = policy.get(key)
if val:
return _normalize_platforms(val)
# Infer from @odata.type (e.g. #microsoft.graph.iosCompliancePolicy)
return _platform_from_odata(policy.get("@odata.type", ""))
@@ -145,7 +163,6 @@ def _extract_platform(policy: dict, category: str = "") -> str:
# ---------------------------------------------------------------------------
def _load_groups(root: Path) -> dict[str, str]:
"""Return groupId → displayName from MigrationTable.json (created by IntuneManagement export)."""
path = root / "MigrationTable.json"
if not path.is_file():
return {}
@@ -161,7 +178,6 @@ def _load_groups(root: Path) -> dict[str, str]:
def _resolve_target(target: dict, groups: dict[str, str]) -> tuple[str, str]:
"""Returns (intent, display_name)."""
ttype = target.get("@odata.type", "")
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
return "include", "All devices"
@@ -218,7 +234,6 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str,
name = f"{parent} > {name}"
children: list[dict] = []
base_row = {"Policy": policy, "Platform": platform}
if "ChoiceSettingInstance" in otype and "Collection" not in otype:
@@ -260,7 +275,100 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str,
# ---------------------------------------------------------------------------
# Processors
# Intent settings walker (Endpoint Security / Device Management Intents)
# Old-style API: /deviceManagement/intents/{id}/settings
# Each item has definitionId + value/valueJson instead of settingInstance
# ---------------------------------------------------------------------------
def _intent_def_name(definition_id: str) -> str:
"""Human name from intent definitionId like 'category--type_settingName'."""
tail = definition_id.rsplit("_", 1)[-1]
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", tail).title()
def _walk_intent(si: dict, policy: str, platform: str, parent: str = "") -> list[dict]:
rows: list[dict] = []
defid = si.get("definitionId", "")
name = _intent_def_name(defid)
if parent:
name = f"{parent} > {name}"
base = {"Policy": policy, "Platform": platform}
value = si.get("value")
value_json = si.get("valueJson", "")
def _emit(v: Any) -> None:
if isinstance(v, list):
dict_children = [c for c in v if isinstance(c, dict)]
primitives = [c for c in v if not isinstance(c, dict)]
for child in dict_children:
rows.extend(_walk_intent(child, policy, platform, parent=name))
if primitives:
rows.append({**base, "Setting": name,
"Value": "; ".join(str(x) for x in primitives)})
elif v is not None:
rows.append({**base, "Setting": name, "Value": str(v)})
if value is not None:
_emit(value)
elif value_json and value_json != "null":
try:
_emit(json.loads(value_json))
except json.JSONDecodeError:
rows.append({**base, "Setting": name, "Value": value_json})
return rows
# ---------------------------------------------------------------------------
# OMA-URI and customSettings helpers
# ---------------------------------------------------------------------------
def _expand_oma_settings(oma_list: list, base_row: dict) -> list[dict]:
rows = []
for oma in oma_list:
if not isinstance(oma, dict):
continue
uri = oma.get("omaUri", "")
display = oma.get("displayName", "")
setting_name = f"{uri} ({display})" if display else uri
raw = oma.get("value")
if raw is None:
raw = oma.get("secretReferenceValueId", "")
if isinstance(raw, (bool, int, float)):
value_str = str(raw)
elif isinstance(raw, str):
value_str = raw
else:
value_str = json.dumps(raw, ensure_ascii=False) if raw is not None else ""
rows.append({**base_row, "Setting": setting_name, "Value": value_str})
return rows
def _expand_custom_settings(cs_list: list, base_row: dict) -> list[dict]:
rows = []
for cs in cs_list:
if not isinstance(cs, dict):
continue
sname = cs.get("name") or cs.get("key") or ""
value = str(cs.get("value") or "")
if sname:
rows.append({**base_row, "Setting": f"customSettings/{sname}", "Value": value})
return rows
def _decode_b64_text(b64_str: str) -> str:
try:
text = base64.b64decode(b64_str).decode("utf-8", errors="replace").strip()
if len(text) > _SCRIPT_PREVIEW_CHARS:
return text[:_SCRIPT_PREVIEW_CHARS] + f"… [{len(text)} chars]"
return text
except Exception:
return f"[base64 {len(b64_str)} chars]"
# ---------------------------------------------------------------------------
# Folder resolution
# ---------------------------------------------------------------------------
def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
@@ -271,24 +379,151 @@ def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
return None
# ---------------------------------------------------------------------------
# Processors
# ---------------------------------------------------------------------------
def process_settings_catalog(root: Path, catalog: dict[str, Any],
groups: dict[str, str],
include_assignments: bool) -> list[dict]:
folder = _resolve_folder(root, "SettingsCatalog", "Settings Catalog")
"""Settings Catalog + Compliance Policies V2 — both use settings[].settingInstance."""
folder_groups = [
("SettingsCatalog", "Settings Catalog"),
("CompliancePoliciesV2", "Compliance Policies - V2"),
]
rows: list[dict] = []
seen: set[Path] = set()
for candidates in folder_groups:
folder = _resolve_folder(root, *candidates)
if folder is None or folder in seen:
continue
seen.add(folder)
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy = json.load(f)
policy_name = policy.get("name") or policy.get("displayName") or path.stem
platform = _extract_platform(policy)
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
for setting in policy.get("settings", []):
si = setting.get("settingInstance", {})
for row in _walk(si, catalog, policy_name, platform):
row.update(assignment_cols)
rows.append(row)
return rows
def process_intent_settings(root: Path, groups: dict[str, str],
include_assignments: bool) -> list[dict]:
"""Endpoint Security + Device Management Intents.
IntuneManagement exports policy metadata to <Name>.json and settings to
<Name>_Settings.json via the /deviceManagement/intents/{id}/settings endpoint.
Settings use the old intent format: definitionId + value/valueJson.
"""
folder_groups = [
("EndpointSecurity", "Endpoint Security"),
("DeviceManagementIntents", "Device Management Intents"),
]
rows: list[dict] = []
seen: set[Path] = set()
for candidates in folder_groups:
folder = _resolve_folder(root, *candidates)
if folder is None or folder in seen:
continue
seen.add(folder)
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy_obj = json.load(f)
if not isinstance(policy_obj, dict):
continue
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
platform = _extract_platform(policy_obj)
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
settings_path = path.parent / f"{path.stem}_Settings.json"
settings_list: list = []
if settings_path.is_file():
with settings_path.open(encoding="utf-8") as f:
sd = json.load(f)
settings_list = sd.get("settings", sd) if isinstance(sd, dict) else sd
for si in settings_list:
if isinstance(si, dict):
for row in _walk_intent(si, policy_name, platform):
row.update(assignment_cols)
rows.append(row)
return rows
def process_admx(root: Path, groups: dict[str, str],
include_assignments: bool) -> list[dict]:
"""Administrative Templates — definitionValues in companion _Settings.json.
IntuneManagement removes definitionValues from the main export file
(PropertiesToRemove) and saves them separately via Start-PostExportAdministrativeTemplate.
Each definitionValue has definition.displayName/categoryPath and presentationValues.
"""
folder = _resolve_folder(root, "AdministrativeTemplates", "Administrative Templates")
rows: list[dict] = []
if folder is None:
return rows
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy = json.load(f)
policy_name = policy.get("name") or path.stem
platform = _extract_platform(policy, "SettingsCatalog")
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
for setting in policy.get("settings", []):
si = setting.get("settingInstance", {})
for row in _walk(si, catalog, policy_name, platform):
row.update(assignment_cols)
rows.append(row)
policy_obj = json.load(f)
if not isinstance(policy_obj, dict):
continue
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
def_values: list = policy_obj.get("definitionValues", [])
if not def_values:
settings_path = path.parent / f"{path.stem}_Settings.json"
if settings_path.is_file():
with settings_path.open(encoding="utf-8") as f:
sd = json.load(f)
def_values = (sd.get("definitionValues", sd)
if isinstance(sd, dict) else sd)
for dv in def_values:
if not isinstance(dv, dict):
continue
defn = dv.get("definition") or {}
raw_name = defn.get("displayName") or defn.get("id", "")
cat = defn.get("categoryPath", "").strip("\\").replace("\\", " > ")
setting_name = f"{cat} > {raw_name}" if cat else raw_name
enabled = dv.get("enabled", True)
pres_values = dv.get("presentationValues", [])
if not enabled:
value_str = "Disabled"
elif not pres_values:
value_str = "Enabled"
else:
parts = []
for pv in pres_values:
if not isinstance(pv, dict):
continue
label = (pv.get("presentation") or {}).get("label") or ""
val = pv.get("value")
if isinstance(val, list):
val = "; ".join(str(v) for v in val)
else:
val = str(val) if val is not None else ""
parts.append(f"{label}: {val}" if label else val)
value_str = " | ".join(parts) if parts else "Enabled"
row = {"Policy": policy_name, "Platform": "Windows",
"Setting": setting_name, "Value": value_str}
row.update(assignment_cols)
rows.append(row)
return rows
@@ -303,6 +538,8 @@ def process_flat_category(root: Path, category: str,
folder = folder / "Policies"
rows: list[dict] = []
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy = json.load(f)
if not isinstance(policy, dict):
@@ -310,16 +547,35 @@ def process_flat_category(root: Path, category: str,
policy_name = policy.get("displayName") or policy.get("name") or path.stem
platform = _extract_platform(policy, category)
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
base_row = {"Policy": policy_name, "Platform": platform}
# OMA-URI settings (Device Configuration custom profiles)
if isinstance(policy.get("omaSettings"), list):
for row in _expand_oma_settings(policy["omaSettings"], base_row):
row.update(assignment_cols)
rows.append(row)
# customSettings (App Configuration App, App Protection custom settings)
if isinstance(policy.get("customSettings"), list):
for row in _expand_custom_settings(policy["customSettings"], base_row):
row.update(assignment_cols)
rows.append(row)
for key, value in policy.items():
if key in _SKIP_KEYS or value is None:
if key in _SKIP_KEYS or key in _SPECIAL_KEYS or value is None:
continue
if isinstance(value, (dict, list)):
if key in _B64_TEXT_KEYS:
if isinstance(value, str) and value:
value_str = _decode_b64_text(value)
else:
continue
elif isinstance(value, (dict, list)):
value_str = json.dumps(value, ensure_ascii=False)
if len(value_str) > 500:
value_str = value_str[:497] + "..."
else:
value_str = str(value)
row = {"Policy": policy_name, "Platform": platform, "Setting": key, "Value": value_str}
row = {**base_row, "Setting": key, "Value": value_str}
row.update(assignment_cols)
rows.append(row)
return rows
@@ -342,17 +598,76 @@ def main() -> None:
groups = _load_groups(root) if include_assignments else {}
rows: list[dict] = []
# --- Dedicated structured processors ---
# Settings Catalog + Compliance Policies V2 (settingInstance)
rows.extend(process_settings_catalog(root, catalog, groups, include_assignments))
# Endpoint Security + Device Management Intents (companion _Settings.json, intent format)
rows.extend(process_intent_settings(root, groups, include_assignments))
# Administrative Templates (companion _Settings.json, definitionValues)
rows.extend(process_admx(root, groups, include_assignments))
# --- Flat processors ---
# Device Configuration (flat + OMA-URI expansion for custom profiles)
rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments,
"Device Configuration", "Device Configurations"))
# Compliance Policies V1
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
"Compliance Policies"))
rows.extend(process_flat_category(root, "CompliancePoliciesV2", groups, include_assignments,
"Compliance Policies - V2"))
rows.extend(process_flat_category(root, "EndpointSecurity", groups, include_assignments,
"Endpoint Security"))
rows.extend(process_flat_category(root, "AdministrativeTemplates", groups, include_assignments,
"Administrative Templates"))
# Scripts (scriptContent decoded from base64)
rows.extend(process_flat_category(root, "PowerShellScripts", groups, include_assignments,
"Scripts (PowerShell)"))
rows.extend(process_flat_category(root, "MacScripts", groups, include_assignments,
"Scripts (Shell)"))
rows.extend(process_flat_category(root, "MacCustomAttributes", groups, include_assignments,
"Custom Attributes"))
rows.extend(process_flat_category(root, "ComplianceScripts", groups, include_assignments,
"Compliance Scripts"))
rows.extend(process_flat_category(root, "DeviceHealthScripts", groups, include_assignments,
"Health Scripts"))
# App (customSettings expanded; payloadJson decoded)
rows.extend(process_flat_category(root, "AppProtection", groups, include_assignments,
"App Protection"))
rows.extend(process_flat_category(root, "AppConfigurationManagedApp", groups, include_assignments,
"App Configuration (App)"))
rows.extend(process_flat_category(root, "AppConfigurationManagedDevice", groups, include_assignments,
"App Configuration (Device)"))
# Enrollment
rows.extend(process_flat_category(root, "EnrollmentRestrictions", groups, include_assignments,
"Enrollment Restrictions"))
rows.extend(process_flat_category(root, "EnrollmentStatusPage", groups, include_assignments,
"Enrollment Status Page"))
rows.extend(process_flat_category(root, "AutoPilot", groups, include_assignments,
"Autopilot"))
# Updates
rows.extend(process_flat_category(root, "UpdatePolicies", groups, include_assignments,
"Update Policies"))
rows.extend(process_flat_category(root, "FeatureUpdates", groups, include_assignments,
"Feature Updates"))
rows.extend(process_flat_category(root, "WinFeatureUpdates", groups, include_assignments))
rows.extend(process_flat_category(root, "QualityUpdates", groups, include_assignments,
"Quality Updates"))
rows.extend(process_flat_category(root, "WinQualityUpdates", groups, include_assignments))
rows.extend(process_flat_category(root, "DriverUpdateProfiles", groups, include_assignments,
"Driver Update Profiles"))
rows.extend(process_flat_category(root, "WinDriverUpdatePolicies", groups, include_assignments))
# W365
rows.extend(process_flat_category(root, "W365ProvisioningPolicies", groups, include_assignments,
"W365 Provisioning Policies"))
rows.extend(process_flat_category(root, "W365UserSettings", groups, include_assignments,
"W365 User Settings"))
# Misc
rows.extend(process_flat_category(root, "AssignmentFilters", groups, include_assignments,
"Filters", "Assignment Filters"))
rows.extend(process_flat_category(root, "TermsAndConditions", groups, include_assignments,
"Terms and Conditions"))
rows.extend(process_flat_category(root, "Notifications", groups, include_assignments))
for row in rows:
for col in fieldnames:
@@ -0,0 +1,723 @@
<#PSScriptInfo
.VERSION 1.9.0
.GUID 6c861af7-d12e-4ea2-b5dc-56fee16e0107
.AUTHOR Nicola Suter
.TAGS ConditionalAccess, AzureAD, Identity
.PROJECTURI https://git.cqre.net/vibecoding/CAExporter
.ICONURI https://raw.githubusercontent.com/microsoftgraph/g-raph/master/g-raph.png
.DESCRIPTION This script documents Azure AD Conditional Access Policies using the latest Microsoft.Graph PowerShell module.
.SYNOPSIS This script retrieves all Conditional Access Policies and translates Azure AD Object IDs to display names for users, groups, directory roles, locations...
.EXAMPLE
Connect-MgGraph -Scopes "Application.Read.All", "Group.Read.All", "Policy.Read.All", "RoleManagement.Read.Directory", "User.Read.All"
& .\Invoke-ConditionalAccessDocumentation.ps1
Generates the documentation and exports the csv to the script directory.
.NOTES
Author: Nicola Suter
Creation Date: 31.01.2022
Updated: 15.09.2025
#>
param(
[switch]$ExportExcel,
[string]$ExcelPath
)
# NOTE: Module requirements are handled programmatically below to allow auto-install.
$RequiredGraphVersion = '2.30.0'
$RequiredGraphModules = @(
'Microsoft.Graph.Authentication',
'Microsoft.Graph.Applications',
'Microsoft.Graph.Identity.SignIns',
'Microsoft.Graph.Groups',
'Microsoft.Graph.DirectoryObjects',
'Microsoft.Graph.Identity.DirectoryManagement',
'Microsoft.Graph.Identity.Governance'
)
function Ensure-NuGetProvider {
try {
if (-not (Get-PackageProvider -ListAvailable -Name 'NuGet' -ErrorAction SilentlyContinue)) {
Install-PackageProvider -Name 'NuGet' -Force -Scope CurrentUser | Out-Null
}
} catch { Write-Warning "NuGet provider installation failed: $($_.Exception.Message)" }
}
function Ensure-PSGalleryTrusted {
try {
$repo = Get-PSRepository -Name 'PSGallery' -ErrorAction Stop
if ($repo.InstallationPolicy -ne 'Trusted') {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted -ErrorAction Stop
}
} catch { Write-Warning "Failed to set PSGallery trusted: $($_.Exception.Message)" }
}
function Ensure-Module {
param(
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Version
)
$hasVersion = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue |
Where-Object { $_.Version -eq [version]$Version }
if (-not $hasVersion) {
Write-Verbose "Installing $Name $Version for current user..."
Ensure-NuGetProvider
Ensure-PSGalleryTrusted
try {
Install-Module -Name $Name -RequiredVersion $Version -Scope CurrentUser -Force -ErrorAction Stop
} catch {
Write-Warning "Exact version $Version not available for $Name. Installing latest available version."
Install-Module -Name $Name -Scope CurrentUser -Force -ErrorAction Stop
}
}
Import-Module -Name $Name -ErrorAction Stop | Out-Null
}
foreach ($m in $RequiredGraphModules) { Ensure-Module -Name $m -Version $RequiredGraphVersion }
# If Excel export requested, ensure ImportExcel module is loaded/installed
if ($ExportExcel) {
try {
Import-Module ImportExcel -ErrorAction Stop | Out-Null
} catch {
Write-Host 'Installing ImportExcel module for Excel export...' -ForegroundColor Cyan
Ensure-PSGalleryTrusted
Install-Module -Name ImportExcel -Scope CurrentUser -Force -ErrorAction Stop
Import-Module ImportExcel -ErrorAction Stop | Out-Null
}
# --- Helpers for older ImportExcel versions ---
if (-not (Get-Command New-WorksheetName -ErrorAction SilentlyContinue)) {
function New-WorksheetName {
param([string]$Name)
if (-not $Name) { return 'Sheet' }
$san = ($Name -replace '[\\/:*?\[\]]','_')
if ($san.Length -gt 31) { $san = $san.Substring(0,31) }
if ($san -match '^\d+$') { $san = "_$san" }
return $san
}
}
if (-not (Get-Command Set-Cell -ErrorAction SilentlyContinue)) {
function Set-Cell {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$WorksheetName,
[Parameter(Mandatory)][int]$Row,
[Parameter(Mandatory)][int]$Column,
[string]$Value,
[string]$Hyperlink,
[switch]$Formula
)
# Fallback using EPPlus via ImportExcel helper cmdlets
$pkg = Open-ExcelPackage -Path $Path
try {
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if (-not $ws) { $ws = Add-WorkSheet -ExcelPackage $pkg -WorksheetName $WorksheetName }
$cell = $ws.Cells[$Row,$Column]
if ($Formula) {
try { $cell.Clear() } catch { }
if ($Value -and $Value.StartsWith('=')) { $cell.Formula = $Value.Substring(1) } else { $cell.Formula = $Value }
} else {
$cell.Value = $Value
}
if ($Hyperlink) { $cell.Hyperlink = $Hyperlink }
}
finally {
try { $pkg.Workbook.CalcMode = [OfficeOpenXml.ExcelCalcMode]::Automatic } catch { }
try { $ws.Calculate() } catch { }
try { $pkg.Save() } catch { }
try { $pkg.Dispose() } catch { }
}
}
}
function Try-AddConditionalFormatting {
param(
[string]$Path,
[string]$WorksheetName,
[string]$Range,
[string]$RuleType,
[string]$ConditionValue,
[string]$ForegroundColor,
[string]$BackgroundColor
)
try {
Add-ConditionalFormatting -Path $Path -WorksheetName $WorksheetName -Range $Range -RuleType $RuleType -ConditionValue $ConditionValue -ForegroundColor $ForegroundColor -BackgroundColor $BackgroundColor
} catch {
try {
$pkg = Open-ExcelPackage -Path $Path
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if ($ws) {
$cf = $ws.ConditionalFormatting.AddContainsText($Range)
$cf.Text = $ConditionValue
if ($BackgroundColor) { $cf.Style.Fill.BackgroundColor.SetColor([System.Drawing.Color]::$BackgroundColor) }
}
$pkg.Save(); $pkg.Dispose()
} catch { }
}
}
function Try-SetColumnWidth {
param(
[string]$Path,
[string]$WorksheetName,
[int]$Column,
[double]$Width
)
try {
Set-Column -Path $Path -WorksheetName $WorksheetName -Column $Column -Width $Width
} catch {
try {
$pkg = Open-ExcelPackage -Path $Path
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if ($ws) { $ws.Column($Column).Width = $Width }
$pkg.Save(); $pkg.Dispose()
} catch { }
}
}
function Try-SetFormat {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$WorksheetName,
[Parameter(Mandatory)][string]$Range,
[switch]$Bold,
[int]$FontSize,
[string]$BackgroundColor
)
$ok = $false
try {
Set-Format -Path $Path -WorksheetName $WorksheetName -Range $Range -Bold:$Bold -FontSize $FontSize -BackgroundColor $BackgroundColor
$ok = $true
} catch {}
if (-not $ok) {
try {
Set-Format -Address $Range -WorkSheetname $WorksheetName -Bold:$Bold -FontSize $FontSize -BackgroundColor $BackgroundColor -PassThru | Export-Excel -Path $Path -WorksheetName $WorksheetName -Append
$ok = $true
} catch {}
}
if (-not $ok) {
try {
$pkg = Open-ExcelPackage -Path $Path
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if ($ws) {
$addr = [OfficeOpenXml.ExcelAddress]::new($Range)
$cells = $ws.Cells[$addr.Address]
if ($Bold) { $cells.Style.Font.Bold = $true }
if ($FontSize -gt 0) { $cells.Style.Font.Size = $FontSize }
if ($BackgroundColor) { $cells.Style.Fill.PatternType = [OfficeOpenXml.Style.ExcelFillStyle]::Solid; $cells.Style.Fill.BackgroundColor.SetColor([System.Drawing.Color]::$BackgroundColor) }
}
$pkg.Save(); $pkg.Dispose()
} catch { }
}
}
function Add-PolicyNamesNamedRange {
param([Parameter(Mandatory)][string]$Path)
$pkg = Open-ExcelPackage -Path $Path
try {
$ws = $pkg.Workbook.Worksheets['Master']
if (-not $ws) { return }
$lastCol = $ws.Dimension.End.Column
$nameCol = $null
for ($c=1; $c -le $lastCol; $c++) {
if (($ws.Cells[1,$c].Text) -eq 'Name') { $nameCol = $c; break }
}
if ($null -eq $nameCol) { return }
$lastRow = $ws.Dimension.End.Row
if ($lastRow -lt 2) { return }
$rangeAddress = [OfficeOpenXml.ExcelAddress]::new(2, $nameCol, $lastRow, $nameCol)
$existing = $pkg.Workbook.Names['PolicyNames']
if ($existing) { $pkg.Workbook.Names.Remove($existing) | Out-Null }
[void]$pkg.Workbook.Names.Add('PolicyNames', $ws.Cells[$rangeAddress.Address])
$pkg.Save()
} finally { try { $pkg.Dispose() } catch { } }
}
function Add-PolicyNameValidation {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$WorksheetName
)
$pkg = Open-ExcelPackage -Path $Path
try {
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if (-not $ws) { return }
$dv = $ws.DataValidations.AddListValidation('B1')
$dv.Formula.ExcelFormula = 'PolicyNames'
$dv.ShowErrorMessage = $true
$dv.ErrorTitle = 'Invalid selection'
$dv.Error = 'Please pick a policy name from the list.'
$pkg.Save()
} finally { try { $pkg.Dispose() } catch { } }
}
}
# --- Ensure connection to Microsoft Graph with required scopes ---
$RequiredMgScopes = @(
'Application.Read.All',
'Group.Read.All',
'Policy.Read.All',
'RoleManagement.Read.Directory',
'User.Read.All',
'NetworkAccessPolicy.Read.All', # optional: for Global Secure Access profile names
'Agreement.Read.All' # optional: for Terms of Use display names
)
function Ensure-MgConnection {
try { $ctx = Get-MgContext -ErrorAction Stop } catch { $ctx = $null }
$needConnect = $true
if ($ctx) {
$currentScopes = @($ctx.Scopes)
if ($currentScopes -and ($RequiredMgScopes | Where-Object { $currentScopes -contains $_ }).Count -eq $RequiredMgScopes.Count) {
$needConnect = $false
}
}
if ($needConnect) {
Write-Host 'Connecting to Microsoft Graph...' -ForegroundColor Cyan
Connect-MgGraph -Scopes $RequiredMgScopes -NoWelcome
}
}
Ensure-MgConnection
function Test-Guid {
[Cmdletbinding()]
[OutputType([bool])]
param
(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
[AllowEmptyString()]
[string]$InputObject
)
process {
return [guid]::TryParse($InputObject, $([ref][guid]::Empty))
}
}
function Resolve-MgObject {
[Cmdletbinding()]
[OutputType([string])]
param
(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
[AllowEmptyString()]
[string]$InputObject
)
process {
if (Test-Guid -InputObject $InputObject) {
try {
if ($displayNameCache.ContainsKey($InputObject)) {
Write-Debug "Cached display name for `"$InputObject`""
return $displayNameCache[$InputObject]
} else {
$directoryObject = Get-MgDirectoryObject -DirectoryObjectId $InputObject -ErrorAction Stop
$displayName = $directoryObject.AdditionalProperties['displayName']
$displayNameCache[$InputObject] = $displayName
return $displayName
}
} catch {
Write-Warning "Unable to resolve directory object with ID $InputObject, might have been deleted!"
}
}
return $InputObject
}
}
# Add GetOrDefault to hashtables
$etd = @{
TypeName = 'System.Collections.Hashtable'
MemberType = 'Scriptmethod'
MemberName = 'GetOrDefault'
Value = {
param(
$key,
$defaultValue
)
if (-not [string]::IsNullOrEmpty($key)) {
if ($this.ContainsKey($key)) {
if ($this[$key].DisplayName) {
return $this[$key].DisplayName
} else {
return $this[$key]
}
} else {
return $defaultValue
}
}
}
}
Update-TypeData @etd -Force
Write-Progress -PercentComplete -1 -Activity 'Fetching conditional access policies and related data from Graph API'
try {
if (-not (Get-MgContext)) { Write-Warning "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes \"Application.Read.All\",\"Group.Read.All\",\"Policy.Read.All\",\"RoleManagement.Read.Directory\",\"User.Read.All\",\"NetworkAccessPolicy.Read.All\",\"Agreement.Read.All\"" }
} catch { }
# Get Conditional Access Policies
$conditionalAccessPolicies = Get-MgIdentityConditionalAccessPolicy -ExpandProperty '*' -All -ErrorAction Stop
# Get Conditional Access Named / Trusted Locations
$namedLocations = Get-MgIdentityConditionalAccessNamedLocation -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
if (-not $namedLocations) { $namedLocations = @{} }
# Get Azure AD Directory Role Templates
try {
$directoryRoleTemplates = Get-MgDirectoryRoleTemplate -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
} catch {
Write-Warning "Directory role templates could not be retrieved (module missing or insufficient permissions). Role names will not be resolved."
$directoryRoleTemplates = @{}
}
# Service Principals
$servicePrincipals = Get-MgServicePrincipal -All -ErrorAction Stop | Group-Object -Property AppId -AsHashTable
# Terms of Use Agreements
try {
$termsOfUseAgreements = Get-MgIdentityGovernanceTermsOfUseAgreement -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
} catch {
Write-Warning "Terms of Use agreements could not be retrieved or permission missing (Agreement.Read.All)."
$termsOfUseAgreements = @{}
}
# GSA network filtering profiles
try {
$networkFilteringProfiles = Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/beta/networkAccess/filteringProfiles' -Method GET -OutputType PSObject -ErrorAction Stop |
Select-Object -ExpandProperty value |
Group-Object -Property id -AsHashTable
} catch {
Write-Warning "Global Secure Access filtering profiles not available or insufficient permission. Skipping."
$networkFilteringProfiles = @{}
}
# Init report
$documentation = [System.Collections.Generic.List[Object]]::new()
# Cache for resolved display names
$displayNameCache = @{}
# Process all Conditional Access Policies
foreach ($policy in $conditionalAccessPolicies) {
$currentIndex = $conditionalAccessPolicies.indexOf($policy) + 1
$progress = @{
Activity = 'Generating Conditional Access Documentation...'
PercentComplete = [Decimal]::Divide($currentIndex, $conditionalAccessPolicies.Count) * 100
CurrentOperation = "Processing Policy `"$($policy.DisplayName)`""
}
if ($currentIndex -eq $conditionalAccessPolicies.Count) { $progress.Add('Completed', $true) }
Write-Progress @progress
Write-Output "Processing policy `"$($policy.DisplayName)`""
try {
$includeUsers = @($policy.Conditions?.Users?.IncludeUsers) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$excludeUsers = @($policy.Conditions?.Users?.ExcludeUsers) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$includeGroups = @($policy.Conditions?.Users?.IncludeGroups) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$excludeGroups = @($policy.Conditions?.Users?.ExcludeGroups) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$includeRoles = @($policy.Conditions?.Users?.IncludeRoles) | ForEach-Object { $directoryRoleTemplates.GetOrDefault($_, $_) }
$excludeRoles = @($policy.Conditions?.Users?.ExcludeRoles) | ForEach-Object { $directoryRoleTemplates.GetOrDefault($_, $_) }
$includeApps = @($policy.Conditions?.Applications?.IncludeApplications) | ForEach-Object { $servicePrincipals.GetOrDefault($_, $_) }
$excludeApps = @($policy.Conditions?.Applications?.ExcludeApplications) | ForEach-Object { $servicePrincipals.GetOrDefault($_, $_) }
$includeServicePrincipals = [System.Collections.Generic.List[Object]]::new()
$excludeServicePrincipals = [System.Collections.Generic.List[Object]]::new()
@($policy.Conditions?.ClientApplications?.IncludeServicePrincipals) | ForEach-Object { $includeServicePrincipals.Add($servicePrincipals.GetOrDefault($_, $_)) }
@($policy.Conditions?.ClientApplications?.ExcludeServicePrincipals) | ForEach-Object { $excludeServicePrincipals.Add($servicePrincipals.GetOrDefault($_, $_)) }
$includeAuthenticationContext = [System.Collections.Generic.List[Object]]::new()
@($policy.Conditions?.Applications?.IncludeAuthenticationContextClassReferences) | ForEach-Object {
try {
$context = Get-MgIdentityConditionalAccessAuthenticationContextClassReference -Filter "Id eq '$PSItem'" -ErrorAction Stop
if ($context.DisplayName) { $includeAuthenticationContext.Add($context.DisplayName) }
} catch {
$includeAuthenticationContext.Add($PSItem)
}
}
$includeLocations = @($policy.Conditions?.Locations?.IncludeLocations) | ForEach-Object { $namedLocations.GetOrDefault($_, $_) }
$excludeLocations = @($policy.Conditions?.Locations?.ExcludeLocations) | ForEach-Object { $namedLocations.GetOrDefault($_, $_) }
$webFilteringProfile = $null
try {
$gsaProp = $policy.SessionControls?.AdditionalProperties?['globalSecureAccessFilteringProfile']
if ($gsaProp) {
$profileId = $gsaProp['profileId']
if ($profileId -and $networkFilteringProfiles.ContainsKey($profileId)) {
$webFilteringProfile = $networkFilteringProfiles[$profileId].name
}
}
} catch { }
$separator = "; "
$grantBuiltIn = @()
if ($policy.GrantControls?.BuiltInControls) { $grantBuiltIn += $policy.GrantControls.BuiltInControls }
if ($policy.GrantControls?.TermsOfUse) { $grantBuiltIn += 'termsOfUse' }
if ($policy.GrantControls?.AuthenticationStrength) { $grantBuiltIn += 'authenticationStrength' }
$grantControls = $grantBuiltIn | Where-Object { $_ -ne 'authenticationStrength' }
$authStrengthName = $policy.GrantControls?.AuthenticationStrength?.DisplayName
if ($authStrengthName) { $grantControls += 'authenticationStrength' }
$authStrengthAllowed = @($policy.GrantControls?.AuthenticationStrength?.AllowedCombinations) -join $separator
$signInFrequency = $null
if ($policy.SessionControls?.SignInFrequency?.Value) {
$signInFrequency = "{0} {1}" -f $policy.SessionControls.SignInFrequency.Value, $policy.SessionControls.SignInFrequency.Type
}
$secureSignInSession = $null
$ss = $policy.SessionControls?.AdditionalProperties?['secureSignInSession']
if ($ss) { $secureSignInSession = $ss.isEnabled }
$includeDeviceStates = @($policy.Conditions?.DeviceStates?.IncludeStates)
$excludeDeviceStates = @($policy.Conditions?.DeviceStates?.ExcludeStates)
$includeGuestsOrExternalUserTypes = $policy.Conditions?.Users?.IncludeGuestsOrExternalUsers?.guestOrExternalUserTypes
$includeGuestsOrExternalUserTenants = @($policy.Conditions?.Users?.IncludeGuestsOrExternalUsers?.externalTenants?.AdditionalProperties?['members'])
$authenticationFlows = @($policy.Conditions?.AuthenticationFlows)
$applicationsAdditional = $null
if ($policy.Conditions?.Applications?.AdditionalProperties) {
$applicationsAdditional = ($policy.Conditions.Applications.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$conditionsAdditional = $null
if ($policy.Conditions?.AdditionalProperties) {
$conditionsAdditional = ($policy.Conditions.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$termsOfUseNames = $null
if ($policy.GrantControls?.TermsOfUse) {
$termsOfUseNames = ($policy.GrantControls.TermsOfUse | ForEach-Object { $termsOfUseAgreements.GetOrDefault($_, $_) }) -join $separator
}
$grantControlsAdditional = $null
if ($policy.GrantControls?.AdditionalProperties) {
$grantControlsAdditional = ($policy.GrantControls.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$cloudAppSecurityMode = $policy.SessionControls?.CloudAppSecurity?.Mode
$sessionAdditional = $null
if ($policy.SessionControls?.AdditionalProperties) {
$sessionAdditional = ($policy.SessionControls.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$documentation.Add(
[PSCustomObject]@{
Name = $policy.DisplayName
IncludeUsers = ($includeUsers -join $separator)
IncludeGroups = ($includeGroups -join $separator)
IncludeRoles = ($includeRoles -join $separator)
ExcludeUsers = ($excludeUsers -join $separator)
ExcludeGuestOrExternalUserTypes = $policy.Conditions?.Users?.ExcludeGuestsOrExternalUsers?.guestOrExternalUserTypes
ExcludeGuestOrExternalUserTenants = (@($policy.Conditions?.Users?.ExcludeGuestsOrExternalUsers?.externalTenants?.AdditionalProperties?['members']) -join $separator)
ExcludeGroups = ($excludeGroups -join $separator)
ExcludeRoles = ($excludeRoles -join $separator)
IncludeApps = ($includeApps -join $separator)
ExcludeApps = ($excludeApps -join $separator)
ApplicationFilterMode = $policy.Conditions?.Applications?.ApplicationFilter?.mode
ApplicationFilterRule = $policy.Conditions?.Applications?.ApplicationFilter?.rule
IncludeAuthenticationContext = ($includeAuthenticationContext -join $separator)
IncludeUserActions = (@($policy.Conditions?.Applications?.IncludeUserActions) -join $separator)
ClientAppTypes = (@($policy.Conditions?.ClientAppTypes) -join $separator)
IncludePlatforms = (@($policy.Conditions?.Platforms?.IncludePlatforms) -join $separator)
ExcludePlatforms = (@($policy.Conditions?.Platforms?.ExcludePlatforms) -join $separator)
IncludeLocations = ($includeLocations -join $separator)
ExcludeLocations = ($excludeLocations -join $separator)
DeviceFilterMode = $policy.Conditions?.Devices?.DeviceFilter?.Mode
DeviceFilterRule = $policy.Conditions?.Devices?.DeviceFilter?.Rule
SignInRiskLevels = (@($policy.Conditions?.SignInRiskLevels) -join $separator)
UserRiskLevels = (@($policy.Conditions?.UserRiskLevels) -join $separator)
ServicePrincipalRiskLevels = (@($policy.Conditions?.servicePrincipalRiskLevels) -join $separator)
IncludeDeviceStates = (@($includeDeviceStates) -join $separator)
ExcludeDeviceStates = (@($excludeDeviceStates) -join $separator)
IncludeGuestsOrExternalUserTypes = $includeGuestsOrExternalUserTypes
IncludeGuestOrExternalUserTenants = (@($includeGuestsOrExternalUserTenants) -join $separator)
AuthenticationFlows = (@($authenticationFlows) -join $separator)
ApplicationsAdditional = $applicationsAdditional
ConditionsAdditional = $conditionsAdditional
IncludeServicePrincipals = ($includeServicePrincipals -join $separator)
ExcludeServicePrincipals = ($excludeServicePrincipals -join $separator)
ServicePrincipalFilterMode = $policy.Conditions?.ClientApplications?.ServicePrincipalFilter?.mode
ServicePrincipalFilter = $policy.Conditions?.ClientApplications?.ServicePrincipalFilter?.rule
GrantControls = ($grantControls -join $separator)
GrantControlsOperator = $policy.GrantControls?.Operator
AuthenticationStrength = $authStrengthName
AuthenticationStrengthAllowedCombinations = $authStrengthAllowed
TermsOfUseNames = $termsOfUseNames
GrantControlsAdditional = $grantControlsAdditional
ApplicationEnforcedRestrictions = $policy.SessionControls?.ApplicationEnforcedRestrictions?.IsEnabled
CloudAppSecurity = $policy.SessionControls?.CloudAppSecurity?.IsEnabled
CloudAppSecurityMode = $cloudAppSecurityMode
DisableResilienceDefaults = $policy.SessionControls?.DisableResilienceDefaults
PersistentBrowser = $policy.SessionControls?.PersistentBrowser?.Mode
SignInFrequency = $signInFrequency
SecureSignInSession = $secureSignInSession
GlobalSecureAccessFilteringProfile = $webFilteringProfile
SessionControlsAdditional = $sessionAdditional
State = $policy.State
}
)
} catch {
Write-Error $PSItem
}
}
# Build export path (script directory)
$exportPath = Join-Path $PSScriptRoot 'ConditionalAccessDocumentation.csv'
$CsvDelimiter = ';'
$exportParams = @{ Path = $exportPath; NoTypeInformation = $true; Delimiter = $CsvDelimiter; Encoding = 'utf8BOM' }
try {
$exportParams['UseQuotes'] = 'AsNeeded'
} catch { }
try {
$documentation | Export-Csv @exportParams
} catch {
Write-Warning "Export-Csv with UTF-8 BOM failed on this PowerShell version. Retrying with UTF-8 (no BOM)."
$exportParams['Encoding'] = 'utf8'
$documentation | Export-Csv @exportParams
}
Write-Output "Exported Documentation to '$($exportPath)'"
if ($ExportExcel) {
Write-Host 'Building Excel workbook...' -ForegroundColor Cyan
if (-not $ExcelPath) {
$ExcelPath = Join-Path $PSScriptRoot 'ConditionalAccessDocumentation.xlsx'
}
if (Test-Path $ExcelPath) { Remove-Item $ExcelPath -Force }
$documentation | Export-Excel -Path $ExcelPath -WorksheetName 'Master' -TableName 'Master' -TableStyle 'Medium9' -ClearSheet -FreezeTopRow -AutoFilter
$summary = $documentation | Select-Object Name, State
$summary | Export-Excel -Path $ExcelPath -WorksheetName 'Summary' -TableName 'Policies' -TableStyle 'Medium6' -ClearSheet -FreezeTopRow -AutoFilter
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'enabled' -ForegroundColor 'Black' -BackgroundColor 'LightGreen'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'disabled' -ForegroundColor 'Black' -BackgroundColor 'LightGray'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'reportOnly' -ForegroundColor 'Black' -BackgroundColor 'Khaki'
Add-PolicyNamesNamedRange -Path $ExcelPath
$sections = @(
@{ Title = 'General'; Fields = @(
@{ Label = 'Policy name'; Col = 'Name' },
@{ Label = 'State'; Col = 'State' }
)},
@{ Title = 'Users and groups'; Fields = @(
@{ Label = 'Include users'; Col = 'IncludeUsers' },
@{ Label = 'Include groups'; Col = 'IncludeGroups' },
@{ Label = 'Include roles'; Col = 'IncludeRoles' },
@{ Label = 'Exclude users'; Col = 'ExcludeUsers' },
@{ Label = 'Exclude groups'; Col = 'ExcludeGroups' },
@{ Label = 'Exclude roles'; Col = 'ExcludeRoles' }
)},
@{ Title = 'Applications'; Fields = @(
@{ Label = 'Include apps'; Col = 'IncludeApps' },
@{ Label = 'Exclude apps'; Col = 'ExcludeApps' },
@{ Label = 'Client app types';Col = 'ClientAppTypes' },
@{ Label = 'AuthN context'; Col = 'IncludeAuthenticationContext' }
)},
@{ Title = 'Conditions'; Fields = @(
@{ Label = 'Platforms include'; Col = 'IncludePlatforms' },
@{ Label = 'Platforms exclude'; Col = 'ExcludePlatforms' },
@{ Label = 'Locations include'; Col = 'IncludeLocations' },
@{ Label = 'Locations exclude'; Col = 'ExcludeLocations' },
@{ Label = 'Device filter mode'; Col = 'DeviceFilterMode' },
@{ Label = 'Device filter rule'; Col = 'DeviceFilterRule' },
@{ Label = 'Sign-in risk'; Col = 'SignInRiskLevels' },
@{ Label = 'User risk'; Col = 'UserRiskLevels' }
)},
@{ Title = 'Grant'; Fields = @(
@{ Label = 'Operator'; Col = 'GrantControlsOperator' },
@{ Label = 'Controls'; Col = 'GrantControls' },
@{ Label = 'Auth strength'; Col = 'AuthenticationStrength' },
@{ Label = 'Allowed combos'; Col = 'AuthenticationStrengthAllowedCombinations' },
@{ Label = 'Terms of Use'; Col = 'TermsOfUseNames' }
)},
@{ Title = 'Session'; Fields = @(
@{ Label = 'App enforced restrictions'; Col = 'ApplicationEnforcedRestrictions' },
@{ Label = 'Defender for Cloud Apps'; Col = 'CloudAppSecurity' },
@{ Label = 'CAS mode'; Col = 'CloudAppSecurityMode' },
@{ Label = 'Persistent browser'; Col = 'PersistentBrowser' },
@{ Label = 'Sign-in frequency'; Col = 'SignInFrequency' },
@{ Label = 'Secure sign-in session'; Col = 'SecureSignInSession' },
@{ Label = 'GSA filtering profile'; Col = 'GlobalSecureAccessFilteringProfile' }
)}
)
$first = $documentation | Select-Object -First 1
$masterHeaders = @()
if ($first) { $masterHeaders = @($first.PSObject.Properties.Name) }
$headerIndex = @{}
for ($i=0; $i -lt $masterHeaders.Count; $i++) { $headerIndex[$masterHeaders[$i]] = $i+1 }
foreach ($item in $documentation) {
$sheetName = New-WorksheetName -Name $item.Name
Export-Excel -Path $ExcelPath -WorksheetName $sheetName -ClearSheet | Out-Null
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row 1 -Column 1 -Value 'Policy'
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row 1 -Column 2 -Value $item.Name
Try-SetFormat -Path $ExcelPath -WorksheetName $sheetName -Range 'A1:B1' -Bold -FontSize 14
$rowPtr = 3
foreach ($section in $sections) {
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 1 -Value $section.Title
Try-SetFormat -Path $ExcelPath -WorksheetName $sheetName -Range ("A$rowPtr:B$rowPtr") -Bold -BackgroundColor 'LightGray'
$rowPtr++
foreach ($f in $section.Fields) {
$label = $f.Label
$colName = $f.Col
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 1 -Value $label
if ($headerIndex.ContainsKey($colName)) {
$formula = "=INDEX(Master[$colName], MATCH(`$B`$1, Master[Name], 0))"
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 2 -Value $formula -Formula
} else {
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 2 -Value ''
}
$rowPtr++
}
$rowPtr++
}
Try-SetColumnWidth -Path $ExcelPath -WorksheetName $sheetName -Column 1 -Width 34
Try-SetColumnWidth -Path $ExcelPath -WorksheetName $sheetName -Column 2 -Width 80
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'enabled' -ForegroundColor 'Black' -BackgroundColor 'LightGreen'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'disabled' -ForegroundColor 'Black' -BackgroundColor 'LightGray'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'reportOnly' -ForegroundColor 'Black' -BackgroundColor 'Khaki'
}
# Hyperlinks from Summary -> policy sheets
$r = 2
foreach ($name in $documentation | Select-Object -ExpandProperty Name) {
$ws = New-WorksheetName -Name $name
Set-Cell -Path $ExcelPath -WorksheetName 'Summary' -Row $r -Column 1 -Value $name -Hyperlink ("#'" + $ws + "'!A1")
$r++
}
Write-Output "Exported Excel workbook to '$ExcelPath'"
}
+19
View File
@@ -308,6 +308,7 @@ $commonParams = @{
}
$menuItems = @(
"19. Document Conditional Access policies"
"18. Rotate app secret"
"17. Deploy CIS M365 baseline"
"16. Generate reports"
@@ -513,6 +514,24 @@ while($true)
continue
}
if($choiceNumber -eq 19)
{
$caScript = Join-Path $projectRoot "Scripts/Invoke-ConditionalAccessDocumentation.ps1"
$ans = Read-Host "Export to Excel as well? [y/N]"
$doExcel = $ans -like 'y*'
$caParams = @{}
if($doExcel)
{
$caParams.ExportExcel = $true
$xlPath = Read-Host "Excel output path (Enter for default: Scripts/ConditionalAccessDocumentation.xlsx)"
if(-not [string]::IsNullOrWhiteSpace($xlPath)) { $caParams.ExcelPath = $xlPath }
}
& $caScript @caParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if(-not $script)
{
continue