diff --git a/.gitignore b/.gitignore index 344f3af..fda8a63 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG_macOS_IntuneToolkit.md b/CHANGELOG_macOS_IntuneToolkit.md index 09e58f7..8f616fc 100644 --- a/CHANGELOG_macOS_IntuneToolkit.md +++ b/CHANGELOG_macOS_IntuneToolkit.md @@ -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 diff --git a/Scripts/Export-SettingsReport.py b/Scripts/Export-SettingsReport.py index cf71653..dadc14d 100644 --- a/Scripts/Export-SettingsReport.py +++ b/Scripts/Export-SettingsReport.py @@ -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 .json and settings to + _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: diff --git a/Scripts/Invoke-ConditionalAccessDocumentation.ps1 b/Scripts/Invoke-ConditionalAccessDocumentation.ps1 new file mode 100644 index 0000000..d70d6ad --- /dev/null +++ b/Scripts/Invoke-ConditionalAccessDocumentation.ps1 @@ -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'" +} diff --git a/Start-IntuneToolkit.ps1 b/Start-IntuneToolkit.ps1 index 98ec1d6..4c0d44d 100644 --- a/Start-IntuneToolkit.ps1 +++ b/Start-IntuneToolkit.ps1 @@ -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