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:
@@ -20,6 +20,8 @@ IntuneManagement.log
|
|||||||
Exporting */
|
Exporting */
|
||||||
*.backup/
|
*.backup/
|
||||||
*.backup
|
*.backup
|
||||||
|
Scripts/ConditionalAccessDocumentation.csv
|
||||||
|
Scripts/ConditionalAccessDocumentation.xlsx
|
||||||
|
|
||||||
# Graph metadata cache (now stored in the platform-specific data folder)
|
# Graph metadata cache (now stored in the platform-specific data folder)
|
||||||
GraphMetaData.xml
|
GraphMetaData.xml
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# macOS Intune Toolkit Changelog
|
# 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
|
## 2026-06-14 — Auto-export Settings Catalog definitions for report resolution
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
|
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
|
||||||
|
|
||||||
Covers Settings Catalog policies (human-readable names resolved from
|
Covers:
|
||||||
configurationSettings.json when present) and flat Device Configuration /
|
- Settings Catalog + Compliance Policies V2 (settingInstance structure)
|
||||||
Compliance Policy objects.
|
- 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
|
Output columns: Policy, Platform, Setting, Value
|
||||||
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
||||||
@@ -13,6 +22,7 @@ With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -20,8 +30,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
OUTPUT_FILE = "settings-report.csv"
|
OUTPUT_FILE = "settings-report.csv"
|
||||||
|
|
||||||
BASE_FIELDNAMES = ["Policy", "Platform", "Setting", "Value"]
|
BASE_FIELDNAMES = ["Policy", "Platform", "Setting", "Value"]
|
||||||
|
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
|
||||||
|
|
||||||
_PLATFORM_LABELS = {
|
_PLATFORM_LABELS = {
|
||||||
"windows10": "Windows 10/11",
|
"windows10": "Windows 10/11",
|
||||||
@@ -34,7 +44,6 @@ _PLATFORM_LABELS = {
|
|||||||
"linux": "Linux",
|
"linux": "Linux",
|
||||||
"chromeOS": "Chrome OS",
|
"chromeOS": "Chrome OS",
|
||||||
}
|
}
|
||||||
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
|
|
||||||
|
|
||||||
_SKIP_KEYS = {
|
_SKIP_KEYS = {
|
||||||
"@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version",
|
"@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version",
|
||||||
@@ -44,8 +53,23 @@ _SKIP_KEYS = {
|
|||||||
"deviceManagementApplicabilityRuleOsVersion", "deviceManagementApplicabilityRuleDeviceMode",
|
"deviceManagementApplicabilityRuleOsVersion", "deviceManagementApplicabilityRuleDeviceMode",
|
||||||
"supportsScopeTags", "settingCount", "priorityMetaData", "creationSource",
|
"supportsScopeTags", "settingCount", "priorityMetaData", "creationSource",
|
||||||
"templateReference", "name", "platforms", "technologies",
|
"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:
|
def parse_args() -> argparse.Namespace:
|
||||||
p = argparse.ArgumentParser(description=__doc__)
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
p.add_argument("--root", required=True,
|
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:
|
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")
|
platforms = policy.get("platforms")
|
||||||
if platforms:
|
if platforms:
|
||||||
return _normalize_platforms(platforms)
|
return _normalize_platforms(platforms)
|
||||||
|
|
||||||
# Legacy policies sometimes expose platform/platformType directly
|
|
||||||
for key in ("platform", "platformType"):
|
for key in ("platform", "platformType"):
|
||||||
val = policy.get(key)
|
val = policy.get(key)
|
||||||
if val:
|
if val:
|
||||||
return _normalize_platforms(val)
|
return _normalize_platforms(val)
|
||||||
|
|
||||||
# Infer from @odata.type (e.g. #microsoft.graph.iosCompliancePolicy)
|
|
||||||
return _platform_from_odata(policy.get("@odata.type", ""))
|
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]:
|
def _load_groups(root: Path) -> dict[str, str]:
|
||||||
"""Return groupId → displayName from MigrationTable.json (created by IntuneManagement export)."""
|
|
||||||
path = root / "MigrationTable.json"
|
path = root / "MigrationTable.json"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return {}
|
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]:
|
def _resolve_target(target: dict, groups: dict[str, str]) -> tuple[str, str]:
|
||||||
"""Returns (intent, display_name)."""
|
|
||||||
ttype = target.get("@odata.type", "")
|
ttype = target.get("@odata.type", "")
|
||||||
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
||||||
return "include", "All devices"
|
return "include", "All devices"
|
||||||
@@ -218,7 +234,6 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str,
|
|||||||
name = f"{parent} > {name}"
|
name = f"{parent} > {name}"
|
||||||
|
|
||||||
children: list[dict] = []
|
children: list[dict] = []
|
||||||
|
|
||||||
base_row = {"Policy": policy, "Platform": platform}
|
base_row = {"Policy": policy, "Platform": platform}
|
||||||
|
|
||||||
if "ChoiceSettingInstance" in otype and "Collection" not in otype:
|
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]:
|
def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
|
||||||
@@ -271,18 +379,32 @@ def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Processors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def process_settings_catalog(root: Path, catalog: dict[str, Any],
|
def process_settings_catalog(root: Path, catalog: dict[str, Any],
|
||||||
groups: dict[str, str],
|
groups: dict[str, str],
|
||||||
include_assignments: bool) -> list[dict]:
|
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] = []
|
rows: list[dict] = []
|
||||||
if folder is None:
|
seen: set[Path] = set()
|
||||||
return rows
|
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")):
|
for path in sorted(folder.glob("*.json")):
|
||||||
|
if path.stem.endswith("_Settings"):
|
||||||
|
continue
|
||||||
with path.open(encoding="utf-8") as f:
|
with path.open(encoding="utf-8") as f:
|
||||||
policy = json.load(f)
|
policy = json.load(f)
|
||||||
policy_name = policy.get("name") or path.stem
|
policy_name = policy.get("name") or policy.get("displayName") or path.stem
|
||||||
platform = _extract_platform(policy, "SettingsCatalog")
|
platform = _extract_platform(policy)
|
||||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||||
for setting in policy.get("settings", []):
|
for setting in policy.get("settings", []):
|
||||||
si = setting.get("settingInstance", {})
|
si = setting.get("settingInstance", {})
|
||||||
@@ -292,6 +414,119 @@ def process_settings_catalog(root: Path, catalog: dict[str, Any],
|
|||||||
return rows
|
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_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
|
||||||
|
|
||||||
|
|
||||||
def process_flat_category(root: Path, category: str,
|
def process_flat_category(root: Path, category: str,
|
||||||
groups: dict[str, str],
|
groups: dict[str, str],
|
||||||
include_assignments: bool,
|
include_assignments: bool,
|
||||||
@@ -303,6 +538,8 @@ def process_flat_category(root: Path, category: str,
|
|||||||
folder = folder / "Policies"
|
folder = folder / "Policies"
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for path in sorted(folder.glob("*.json")):
|
for path in sorted(folder.glob("*.json")):
|
||||||
|
if path.stem.endswith("_Settings"):
|
||||||
|
continue
|
||||||
with path.open(encoding="utf-8") as f:
|
with path.open(encoding="utf-8") as f:
|
||||||
policy = json.load(f)
|
policy = json.load(f)
|
||||||
if not isinstance(policy, dict):
|
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
|
policy_name = policy.get("displayName") or policy.get("name") or path.stem
|
||||||
platform = _extract_platform(policy, category)
|
platform = _extract_platform(policy, category)
|
||||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
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():
|
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
|
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)
|
value_str = json.dumps(value, ensure_ascii=False)
|
||||||
if len(value_str) > 500:
|
if len(value_str) > 500:
|
||||||
value_str = value_str[:497] + "..."
|
value_str = value_str[:497] + "..."
|
||||||
else:
|
else:
|
||||||
value_str = str(value)
|
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)
|
row.update(assignment_cols)
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
return rows
|
return rows
|
||||||
@@ -342,17 +598,76 @@ def main() -> None:
|
|||||||
groups = _load_groups(root) if include_assignments else {}
|
groups = _load_groups(root) if include_assignments else {}
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
|
|
||||||
|
# --- Dedicated structured processors ---
|
||||||
|
# Settings Catalog + Compliance Policies V2 (settingInstance)
|
||||||
rows.extend(process_settings_catalog(root, catalog, groups, include_assignments))
|
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,
|
rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments,
|
||||||
"Device Configuration", "Device Configurations"))
|
"Device Configuration", "Device Configurations"))
|
||||||
|
# Compliance Policies V1
|
||||||
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
|
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
|
||||||
"Compliance Policies"))
|
"Compliance Policies"))
|
||||||
rows.extend(process_flat_category(root, "CompliancePoliciesV2", groups, include_assignments,
|
|
||||||
"Compliance Policies - V2"))
|
# Scripts (scriptContent decoded from base64)
|
||||||
rows.extend(process_flat_category(root, "EndpointSecurity", groups, include_assignments,
|
rows.extend(process_flat_category(root, "PowerShellScripts", groups, include_assignments,
|
||||||
"Endpoint Security"))
|
"Scripts (PowerShell)"))
|
||||||
rows.extend(process_flat_category(root, "AdministrativeTemplates", groups, include_assignments,
|
rows.extend(process_flat_category(root, "MacScripts", groups, include_assignments,
|
||||||
"Administrative Templates"))
|
"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 row in rows:
|
||||||
for col in fieldnames:
|
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'"
|
||||||
|
}
|
||||||
@@ -308,6 +308,7 @@ $commonParams = @{
|
|||||||
}
|
}
|
||||||
|
|
||||||
$menuItems = @(
|
$menuItems = @(
|
||||||
|
"19. Document Conditional Access policies"
|
||||||
"18. Rotate app secret"
|
"18. Rotate app secret"
|
||||||
"17. Deploy CIS M365 baseline"
|
"17. Deploy CIS M365 baseline"
|
||||||
"16. Generate reports"
|
"16. Generate reports"
|
||||||
@@ -513,6 +514,24 @@ while($true)
|
|||||||
continue
|
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)
|
if(-not $script)
|
||||||
{
|
{
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user