#!/usr/bin/env python3 """Export a flat CSV of every Intune setting/value pair from a JSON backup. 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 Group names resolved from MigrationTable.json (created by IntuneManagement export). """ from __future__ import annotations import argparse import base64 import csv import json import re 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", "windows10X": "Windows 10X", "windows": "Windows", "macOS": "macOS", "iOS": "iOS", "android": "Android", "androidEnterprise": "Android Enterprise", "linux": "Linux", "chromeOS": "Chrome OS", } _SKIP_KEYS = { "@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version", "displayName", "description", "roleScopeTagIds", "scheduledActionsForRule", "assignments", "deviceStatusOverview", "userStatusOverview", "deviceStatuses", "userStatuses", "deviceManagementApplicabilityRuleOsEdition", "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, help="Path to backup root containing 'Settings Catalog', " "'Device Configurations', etc.") p.add_argument("--output", default=OUTPUT_FILE, help=f"Output CSV file path (default: {OUTPUT_FILE})") p.add_argument("--include-assignments", action="store_true", help="Append AssignmentState, IncludeTargets, ExcludeTargets columns. " "Group names resolved from MigrationTable.json when present.") return p.parse_args() # --------------------------------------------------------------------------- # Catalog lookup (Settings Catalog human-readable names) # --------------------------------------------------------------------------- def _load_catalog(root: Path) -> dict[str, Any]: path = root / "configurationSettings.json" if not path.is_file(): return {} with path.open(encoding="utf-8") as f: raw = json.load(f) entries = raw.get("value", raw) if isinstance(raw, dict) else raw return {e["id"]: e for e in entries if "id" in e} def _setting_name(catalog: dict[str, Any], setting_id: str) -> str: defn = catalog.get(setting_id) if defn: return defn.get("displayName") or defn.get("name") or setting_id tail = setting_id.rsplit("_", 1)[-1] return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", tail).title() def _choice_label(catalog: dict[str, Any], setting_id: str, value_id: str) -> str: defn = catalog.get(setting_id) if defn: for opt in defn.get("options", []): if opt.get("itemId") == value_id: return opt.get("displayName") or value_id suffix = value_id.removeprefix(setting_id).lstrip("_") if suffix == "1": return "Enabled" if suffix == "0": return "Disabled" return suffix.title() if suffix.islower() else suffix or value_id def _normalize_platforms(value: Any) -> str: if value is None or value == "": return "" if isinstance(value, str): items = [v.strip() for v in value.split(",")] elif isinstance(value, list): items = [str(v).strip() for v in value] else: items = [str(value).strip()] labels = [_PLATFORM_LABELS.get(item, item) for item in items if item] return "; ".join(labels) def _platform_from_odata(odata_type: str) -> str: lower = odata_type.lower() if "macos" in lower: return "macOS" if "ios" in lower: return "iOS" if "android" in lower: return "Android" if "windows" in lower: return "Windows" if "linux" in lower: return "Linux" return "" def _extract_platform(policy: dict, category: str = "") -> str: platforms = policy.get("platforms") if platforms: return _normalize_platforms(platforms) for key in ("platform", "platformType"): val = policy.get(key) if val: return _normalize_platforms(val) return _platform_from_odata(policy.get("@odata.type", "")) # --------------------------------------------------------------------------- # Assignment resolution # --------------------------------------------------------------------------- def _load_groups(root: Path) -> dict[str, str]: path = root / "MigrationTable.json" if not path.is_file(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) return { obj["Id"]: obj["DisplayName"] for obj in data.get("Objects", []) if obj.get("Type") == "Group" and obj.get("Id") and obj.get("DisplayName") } except Exception: return {} def _resolve_target(target: dict, groups: dict[str, str]) -> tuple[str, str]: ttype = target.get("@odata.type", "") if ttype == "#microsoft.graph.allDevicesAssignmentTarget": return "include", "All devices" if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget": return "include", "All users" gid = target.get("groupId", "") name = (groups.get(gid) or target.get("groupDisplayName") or target.get("groupName") or gid or "Unresolved group") if ttype == "#microsoft.graph.exclusionGroupAssignmentTarget": return "exclude", name return "include", name def _summarize_assignments(policy: dict, groups: dict[str, str]) -> dict[str, str]: assignments = policy.get("assignments") if not isinstance(assignments, list): return {"AssignmentState": "NotExported", "IncludeTargets": "", "ExcludeTargets": ""} if not assignments: return {"AssignmentState": "Unassigned", "IncludeTargets": "", "ExcludeTargets": ""} include: list[str] = [] exclude: list[str] = [] for item in assignments: if not isinstance(item, dict): continue target = item.get("target") or {} intent, name = _resolve_target(target, groups) if str(item.get("intent", "")).lower() == "exclude" or intent == "exclude": exclude.append(name) else: include.append(name) return { "AssignmentState": "Assigned", "IncludeTargets": "; ".join(sorted(set(include))), "ExcludeTargets": "; ".join(sorted(set(exclude))), } # --------------------------------------------------------------------------- # Settings Catalog recursive walker # --------------------------------------------------------------------------- def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str, parent: str = "") -> list[dict]: rows: list[dict] = [] otype = si.get("@odata.type", "") sid = si.get("settingDefinitionId", "") name = _setting_name(catalog, sid) if parent: name = f"{parent} > {name}" children: list[dict] = [] base_row = {"Policy": policy, "Platform": platform} if "ChoiceSettingInstance" in otype and "Collection" not in otype: csv_val = si.get("choiceSettingValue", {}) value = _choice_label(catalog, sid, csv_val.get("value", "")) rows.append({**base_row, "Setting": name, "Value": value}) children = csv_val.get("children", []) elif "SimpleSettingInstance" in otype and "Collection" not in otype: raw = si.get("simpleSettingValue", {}) value = str(raw.get("value", "")) if isinstance(raw, dict) else str(raw) if value: rows.append({**base_row, "Setting": name, "Value": value}) elif "SimpleSettingCollectionInstance" in otype: vals = [ str(v.get("value", "")) if isinstance(v, dict) else str(v) for v in si.get("simpleSettingCollectionValue", []) ] if vals: rows.append({**base_row, "Setting": name, "Value": "; ".join(vals)}) elif "ChoiceSettingCollectionInstance" in otype: items = si.get("choiceSettingCollectionValue", []) vals = [_choice_label(catalog, sid, item.get("value", "")) for item in items if isinstance(item, dict)] if vals: rows.append({**base_row, "Setting": name, "Value": "; ".join(vals)}) elif "GroupSettingCollectionInstance" in otype: for group in si.get("groupSettingCollectionValue", []): children.extend(group.get("children", [])) for child in children: if isinstance(child, dict): rows.extend(_walk(child, catalog, policy, platform, parent=name)) return rows # --------------------------------------------------------------------------- # 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]: for name in candidates: p = root / name if p.is_dir(): return p return None # --------------------------------------------------------------------------- # Processors # --------------------------------------------------------------------------- def process_settings_catalog(root: Path, catalog: dict[str, Any], groups: dict[str, str], include_assignments: bool) -> list[dict]: """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_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, groups: dict[str, str], include_assignments: bool, *aliases: str) -> list[dict]: folder = _resolve_folder(root, category, *aliases) if folder is None: return [] if (folder / "Policies").is_dir(): 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): continue 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 key in _SPECIAL_KEYS or value is None: continue 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 = {**base_row, "Setting": key, "Value": value_str} row.update(assignment_cols) rows.append(row) return rows # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: args = parse_args() root = Path(args.root) out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) include_assignments: bool = args.include_assignments fieldnames = BASE_FIELDNAMES + (ASSIGNMENT_FIELDNAMES if include_assignments else []) catalog = _load_catalog(root) 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")) # 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: v = row.get(col, "") if isinstance(v, str) and ("\n" in v or "\r" in v): row[col] = v.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") with out_path.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() writer.writerows(rows) print(f"Written {len(rows)} rows → {out_path}") if __name__ == "__main__": main()