#!/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. 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 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"] _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", } ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"] _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", } 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: """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", "")) # --------------------------------------------------------------------------- # Assignment resolution # --------------------------------------------------------------------------- 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 {} 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]: """Returns (intent, display_name).""" 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 # --------------------------------------------------------------------------- # Processors # --------------------------------------------------------------------------- def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]: for name in candidates: p = root / name if p.is_dir(): return p return None 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") rows: list[dict] = [] if folder is None: return rows for path in sorted(folder.glob("*.json")): 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) 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")): 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 {} for key, value in policy.items(): if key in _SKIP_KEYS or value is None: continue if 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.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] = [] rows.extend(process_settings_catalog(root, catalog, groups, include_assignments)) rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments, "Device Configuration", "Device Configurations")) 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")) 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()