90209a7ec4
- 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
688 lines
28 KiB
Python
688 lines
28 KiB
Python
#!/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 <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,
|
|
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()
|