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

- Export-SettingsReport.py: add coverage for Compliance V2, Endpoint
  Security, Device Management Intents, ADMX, scripts (base64 decoded),
  App Protection/Configuration, updates, enrollment, Autopilot, W365,
  filters, and more; add OMA-URI and customSettings expansion
- Start-IntuneToolkit.ps1: add menu item 19 for Conditional Access
  documentation
- Add Invoke-ConditionalAccessDocumentation.ps1 for CA policy docs
  (CSV/Excel)
- .gitignore: exclude CA documentation CSV/XLSX outputs
- CHANGELOG: document the new reporting capabilities
This commit is contained in:
2026-06-29 15:12:37 +02:00
parent 122aa2d4e3
commit 90209a7ec4
5 changed files with 1114 additions and 34 deletions
+349 -34
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env python3
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
Covers Settings Catalog policies (human-readable names resolved from
configurationSettings.json when present) and flat Device Configuration /
Compliance Policy objects.
Covers:
- Settings Catalog + Compliance Policies V2 (settingInstance structure)
- Endpoint Security / Device Management Intents (companion _Settings.json,
old intent API with definitionId + value/valueJson)
- Administrative Templates (companion _Settings.json, definitionValues)
- Device Configuration + Compliance Policies V1 (flat, with OMA-URI expansion)
- Scripts: PowerShell, Shell, Custom Attributes, Health Scripts
(scriptContent / detectionScriptContent / remediationScriptContent decoded from base64)
- App Protection, App Configuration App/Device (flat + customSettings expansion)
- Update, Enrollment, Autopilot, W365, Filters, and other flat types
Human-readable setting names resolved from configurationSettings.json when present.
Output columns: Policy, Platform, Setting, Value
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
@@ -13,6 +22,7 @@ With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
from __future__ import annotations
import argparse
import base64
import csv
import json
import re
@@ -20,8 +30,8 @@ from pathlib import Path
from typing import Any, Optional
OUTPUT_FILE = "settings-report.csv"
BASE_FIELDNAMES = ["Policy", "Platform", "Setting", "Value"]
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
_PLATFORM_LABELS = {
"windows10": "Windows 10/11",
@@ -34,7 +44,6 @@ _PLATFORM_LABELS = {
"linux": "Linux",
"chromeOS": "Chrome OS",
}
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
_SKIP_KEYS = {
"@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version",
@@ -44,8 +53,23 @@ _SKIP_KEYS = {
"deviceManagementApplicabilityRuleOsVersion", "deviceManagementApplicabilityRuleDeviceMode",
"supportsScopeTags", "settingCount", "priorityMetaData", "creationSource",
"templateReference", "name", "platforms", "technologies",
# settings arrays are handled by dedicated processors; avoid JSON blobs in flat categories
"settings",
}
# Expanded by dedicated helpers; excluded from generic flat key loop
_SPECIAL_KEYS = {"omaSettings", "customSettings"}
# Keys with base64-encoded text content
_B64_TEXT_KEYS = {"scriptContent", "detectionScriptContent", "remediationScriptContent", "payloadJson"}
_SCRIPT_PREVIEW_CHARS = 300
# ---------------------------------------------------------------------------
# Args
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--root", required=True,
@@ -124,19 +148,13 @@ def _platform_from_odata(odata_type: str) -> str:
def _extract_platform(policy: dict, category: str = "") -> str:
"""Best-effort platform/OS extraction for Settings Catalog and legacy policies."""
# Settings Catalog direct fields
platforms = policy.get("platforms")
if platforms:
return _normalize_platforms(platforms)
# Legacy policies sometimes expose platform/platformType directly
for key in ("platform", "platformType"):
val = policy.get(key)
if val:
return _normalize_platforms(val)
# Infer from @odata.type (e.g. #microsoft.graph.iosCompliancePolicy)
return _platform_from_odata(policy.get("@odata.type", ""))
@@ -145,7 +163,6 @@ def _extract_platform(policy: dict, category: str = "") -> str:
# ---------------------------------------------------------------------------
def _load_groups(root: Path) -> dict[str, str]:
"""Return groupId → displayName from MigrationTable.json (created by IntuneManagement export)."""
path = root / "MigrationTable.json"
if not path.is_file():
return {}
@@ -161,7 +178,6 @@ def _load_groups(root: Path) -> dict[str, str]:
def _resolve_target(target: dict, groups: dict[str, str]) -> tuple[str, str]:
"""Returns (intent, display_name)."""
ttype = target.get("@odata.type", "")
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
return "include", "All devices"
@@ -218,7 +234,6 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str,
name = f"{parent} > {name}"
children: list[dict] = []
base_row = {"Policy": policy, "Platform": platform}
if "ChoiceSettingInstance" in otype and "Collection" not in otype:
@@ -260,7 +275,100 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str,
# ---------------------------------------------------------------------------
# Processors
# Intent settings walker (Endpoint Security / Device Management Intents)
# Old-style API: /deviceManagement/intents/{id}/settings
# Each item has definitionId + value/valueJson instead of settingInstance
# ---------------------------------------------------------------------------
def _intent_def_name(definition_id: str) -> str:
"""Human name from intent definitionId like 'category--type_settingName'."""
tail = definition_id.rsplit("_", 1)[-1]
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", tail).title()
def _walk_intent(si: dict, policy: str, platform: str, parent: str = "") -> list[dict]:
rows: list[dict] = []
defid = si.get("definitionId", "")
name = _intent_def_name(defid)
if parent:
name = f"{parent} > {name}"
base = {"Policy": policy, "Platform": platform}
value = si.get("value")
value_json = si.get("valueJson", "")
def _emit(v: Any) -> None:
if isinstance(v, list):
dict_children = [c for c in v if isinstance(c, dict)]
primitives = [c for c in v if not isinstance(c, dict)]
for child in dict_children:
rows.extend(_walk_intent(child, policy, platform, parent=name))
if primitives:
rows.append({**base, "Setting": name,
"Value": "; ".join(str(x) for x in primitives)})
elif v is not None:
rows.append({**base, "Setting": name, "Value": str(v)})
if value is not None:
_emit(value)
elif value_json and value_json != "null":
try:
_emit(json.loads(value_json))
except json.JSONDecodeError:
rows.append({**base, "Setting": name, "Value": value_json})
return rows
# ---------------------------------------------------------------------------
# OMA-URI and customSettings helpers
# ---------------------------------------------------------------------------
def _expand_oma_settings(oma_list: list, base_row: dict) -> list[dict]:
rows = []
for oma in oma_list:
if not isinstance(oma, dict):
continue
uri = oma.get("omaUri", "")
display = oma.get("displayName", "")
setting_name = f"{uri} ({display})" if display else uri
raw = oma.get("value")
if raw is None:
raw = oma.get("secretReferenceValueId", "")
if isinstance(raw, (bool, int, float)):
value_str = str(raw)
elif isinstance(raw, str):
value_str = raw
else:
value_str = json.dumps(raw, ensure_ascii=False) if raw is not None else ""
rows.append({**base_row, "Setting": setting_name, "Value": value_str})
return rows
def _expand_custom_settings(cs_list: list, base_row: dict) -> list[dict]:
rows = []
for cs in cs_list:
if not isinstance(cs, dict):
continue
sname = cs.get("name") or cs.get("key") or ""
value = str(cs.get("value") or "")
if sname:
rows.append({**base_row, "Setting": f"customSettings/{sname}", "Value": value})
return rows
def _decode_b64_text(b64_str: str) -> str:
try:
text = base64.b64decode(b64_str).decode("utf-8", errors="replace").strip()
if len(text) > _SCRIPT_PREVIEW_CHARS:
return text[:_SCRIPT_PREVIEW_CHARS] + f"… [{len(text)} chars]"
return text
except Exception:
return f"[base64 {len(b64_str)} chars]"
# ---------------------------------------------------------------------------
# Folder resolution
# ---------------------------------------------------------------------------
def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
@@ -271,24 +379,151 @@ def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
return None
# ---------------------------------------------------------------------------
# Processors
# ---------------------------------------------------------------------------
def process_settings_catalog(root: Path, catalog: dict[str, Any],
groups: dict[str, str],
include_assignments: bool) -> list[dict]:
folder = _resolve_folder(root, "SettingsCatalog", "Settings Catalog")
"""Settings Catalog + Compliance Policies V2 — both use settings[].settingInstance."""
folder_groups = [
("SettingsCatalog", "Settings Catalog"),
("CompliancePoliciesV2", "Compliance Policies - V2"),
]
rows: list[dict] = []
seen: set[Path] = set()
for candidates in folder_groups:
folder = _resolve_folder(root, *candidates)
if folder is None or folder in seen:
continue
seen.add(folder)
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy = json.load(f)
policy_name = policy.get("name") or policy.get("displayName") or path.stem
platform = _extract_platform(policy)
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
for setting in policy.get("settings", []):
si = setting.get("settingInstance", {})
for row in _walk(si, catalog, policy_name, platform):
row.update(assignment_cols)
rows.append(row)
return rows
def process_intent_settings(root: Path, groups: dict[str, str],
include_assignments: bool) -> list[dict]:
"""Endpoint Security + Device Management Intents.
IntuneManagement exports policy metadata to <Name>.json and settings to
<Name>_Settings.json via the /deviceManagement/intents/{id}/settings endpoint.
Settings use the old intent format: definitionId + value/valueJson.
"""
folder_groups = [
("EndpointSecurity", "Endpoint Security"),
("DeviceManagementIntents", "Device Management Intents"),
]
rows: list[dict] = []
seen: set[Path] = set()
for candidates in folder_groups:
folder = _resolve_folder(root, *candidates)
if folder is None or folder in seen:
continue
seen.add(folder)
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy_obj = json.load(f)
if not isinstance(policy_obj, dict):
continue
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
platform = _extract_platform(policy_obj)
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
settings_path = path.parent / f"{path.stem}_Settings.json"
settings_list: list = []
if settings_path.is_file():
with settings_path.open(encoding="utf-8") as f:
sd = json.load(f)
settings_list = sd.get("settings", sd) if isinstance(sd, dict) else sd
for si in settings_list:
if isinstance(si, dict):
for row in _walk_intent(si, policy_name, platform):
row.update(assignment_cols)
rows.append(row)
return rows
def process_admx(root: Path, groups: dict[str, str],
include_assignments: bool) -> list[dict]:
"""Administrative Templates — definitionValues in companion _Settings.json.
IntuneManagement removes definitionValues from the main export file
(PropertiesToRemove) and saves them separately via Start-PostExportAdministrativeTemplate.
Each definitionValue has definition.displayName/categoryPath and presentationValues.
"""
folder = _resolve_folder(root, "AdministrativeTemplates", "Administrative Templates")
rows: list[dict] = []
if folder is None:
return rows
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy = json.load(f)
policy_name = policy.get("name") or path.stem
platform = _extract_platform(policy, "SettingsCatalog")
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
for setting in policy.get("settings", []):
si = setting.get("settingInstance", {})
for row in _walk(si, catalog, policy_name, platform):
row.update(assignment_cols)
rows.append(row)
policy_obj = json.load(f)
if not isinstance(policy_obj, dict):
continue
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
def_values: list = policy_obj.get("definitionValues", [])
if not def_values:
settings_path = path.parent / f"{path.stem}_Settings.json"
if settings_path.is_file():
with settings_path.open(encoding="utf-8") as f:
sd = json.load(f)
def_values = (sd.get("definitionValues", sd)
if isinstance(sd, dict) else sd)
for dv in def_values:
if not isinstance(dv, dict):
continue
defn = dv.get("definition") or {}
raw_name = defn.get("displayName") or defn.get("id", "")
cat = defn.get("categoryPath", "").strip("\\").replace("\\", " > ")
setting_name = f"{cat} > {raw_name}" if cat else raw_name
enabled = dv.get("enabled", True)
pres_values = dv.get("presentationValues", [])
if not enabled:
value_str = "Disabled"
elif not pres_values:
value_str = "Enabled"
else:
parts = []
for pv in pres_values:
if not isinstance(pv, dict):
continue
label = (pv.get("presentation") or {}).get("label") or ""
val = pv.get("value")
if isinstance(val, list):
val = "; ".join(str(v) for v in val)
else:
val = str(val) if val is not None else ""
parts.append(f"{label}: {val}" if label else val)
value_str = " | ".join(parts) if parts else "Enabled"
row = {"Policy": policy_name, "Platform": "Windows",
"Setting": setting_name, "Value": value_str}
row.update(assignment_cols)
rows.append(row)
return rows
@@ -303,6 +538,8 @@ def process_flat_category(root: Path, category: str,
folder = folder / "Policies"
rows: list[dict] = []
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy = json.load(f)
if not isinstance(policy, dict):
@@ -310,16 +547,35 @@ def process_flat_category(root: Path, category: str,
policy_name = policy.get("displayName") or policy.get("name") or path.stem
platform = _extract_platform(policy, category)
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
base_row = {"Policy": policy_name, "Platform": platform}
# OMA-URI settings (Device Configuration custom profiles)
if isinstance(policy.get("omaSettings"), list):
for row in _expand_oma_settings(policy["omaSettings"], base_row):
row.update(assignment_cols)
rows.append(row)
# customSettings (App Configuration App, App Protection custom settings)
if isinstance(policy.get("customSettings"), list):
for row in _expand_custom_settings(policy["customSettings"], base_row):
row.update(assignment_cols)
rows.append(row)
for key, value in policy.items():
if key in _SKIP_KEYS or value is None:
if key in _SKIP_KEYS or key in _SPECIAL_KEYS or value is None:
continue
if isinstance(value, (dict, list)):
if key in _B64_TEXT_KEYS:
if isinstance(value, str) and value:
value_str = _decode_b64_text(value)
else:
continue
elif isinstance(value, (dict, list)):
value_str = json.dumps(value, ensure_ascii=False)
if len(value_str) > 500:
value_str = value_str[:497] + "..."
else:
value_str = str(value)
row = {"Policy": policy_name, "Platform": platform, "Setting": key, "Value": value_str}
row = {**base_row, "Setting": key, "Value": value_str}
row.update(assignment_cols)
rows.append(row)
return rows
@@ -342,17 +598,76 @@ def main() -> None:
groups = _load_groups(root) if include_assignments else {}
rows: list[dict] = []
# --- Dedicated structured processors ---
# Settings Catalog + Compliance Policies V2 (settingInstance)
rows.extend(process_settings_catalog(root, catalog, groups, include_assignments))
# Endpoint Security + Device Management Intents (companion _Settings.json, intent format)
rows.extend(process_intent_settings(root, groups, include_assignments))
# Administrative Templates (companion _Settings.json, definitionValues)
rows.extend(process_admx(root, groups, include_assignments))
# --- Flat processors ---
# Device Configuration (flat + OMA-URI expansion for custom profiles)
rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments,
"Device Configuration", "Device Configurations"))
# Compliance Policies V1
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
"Compliance Policies"))
rows.extend(process_flat_category(root, "CompliancePoliciesV2", groups, include_assignments,
"Compliance Policies - V2"))
rows.extend(process_flat_category(root, "EndpointSecurity", groups, include_assignments,
"Endpoint Security"))
rows.extend(process_flat_category(root, "AdministrativeTemplates", groups, include_assignments,
"Administrative Templates"))
# Scripts (scriptContent decoded from base64)
rows.extend(process_flat_category(root, "PowerShellScripts", groups, include_assignments,
"Scripts (PowerShell)"))
rows.extend(process_flat_category(root, "MacScripts", groups, include_assignments,
"Scripts (Shell)"))
rows.extend(process_flat_category(root, "MacCustomAttributes", groups, include_assignments,
"Custom Attributes"))
rows.extend(process_flat_category(root, "ComplianceScripts", groups, include_assignments,
"Compliance Scripts"))
rows.extend(process_flat_category(root, "DeviceHealthScripts", groups, include_assignments,
"Health Scripts"))
# App (customSettings expanded; payloadJson decoded)
rows.extend(process_flat_category(root, "AppProtection", groups, include_assignments,
"App Protection"))
rows.extend(process_flat_category(root, "AppConfigurationManagedApp", groups, include_assignments,
"App Configuration (App)"))
rows.extend(process_flat_category(root, "AppConfigurationManagedDevice", groups, include_assignments,
"App Configuration (Device)"))
# Enrollment
rows.extend(process_flat_category(root, "EnrollmentRestrictions", groups, include_assignments,
"Enrollment Restrictions"))
rows.extend(process_flat_category(root, "EnrollmentStatusPage", groups, include_assignments,
"Enrollment Status Page"))
rows.extend(process_flat_category(root, "AutoPilot", groups, include_assignments,
"Autopilot"))
# Updates
rows.extend(process_flat_category(root, "UpdatePolicies", groups, include_assignments,
"Update Policies"))
rows.extend(process_flat_category(root, "FeatureUpdates", groups, include_assignments,
"Feature Updates"))
rows.extend(process_flat_category(root, "WinFeatureUpdates", groups, include_assignments))
rows.extend(process_flat_category(root, "QualityUpdates", groups, include_assignments,
"Quality Updates"))
rows.extend(process_flat_category(root, "WinQualityUpdates", groups, include_assignments))
rows.extend(process_flat_category(root, "DriverUpdateProfiles", groups, include_assignments,
"Driver Update Profiles"))
rows.extend(process_flat_category(root, "WinDriverUpdatePolicies", groups, include_assignments))
# W365
rows.extend(process_flat_category(root, "W365ProvisioningPolicies", groups, include_assignments,
"W365 Provisioning Policies"))
rows.extend(process_flat_category(root, "W365UserSettings", groups, include_assignments,
"W365 User Settings"))
# Misc
rows.extend(process_flat_category(root, "AssignmentFilters", groups, include_assignments,
"Filters", "Assignment Filters"))
rows.extend(process_flat_category(root, "TermsAndConditions", groups, include_assignments,
"Terms and Conditions"))
rows.extend(process_flat_category(root, "Notifications", groups, include_assignments))
for row in rows:
for col in fieldnames: