fix(reporting): add Platform column and clean up Windows artifacts
- Export-SettingsReport.py: add Platform column for Settings Catalog (platforms field) and legacy policies (platform/platformType or @odata.type inference) - MSGraph.psm1: store GraphMetaData.xml in cross-platform data folder (Get-CloudApiDataFolder) instead of literal %LOCALAPPDATA% path - MSALAuthentication.psm1: skip TokenCacheHelperEx on non-Windows with an info log instead of failing on missing ProtectedData.dll - .gitignore: remove literal %LOCALAPPDATA% patterns - AGENTS.md, CHANGELOG: document reporting and cross-platform fixes
This commit is contained in:
@@ -5,7 +5,7 @@ Covers Settings Catalog policies (human-readable names resolved from
|
||||
configurationSettings.json when present) and flat Device Configuration /
|
||||
Compliance Policy objects.
|
||||
|
||||
Output columns: Policy, Setting, Value
|
||||
Output columns: Policy, Platform, Setting, Value
|
||||
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
||||
Group names resolved from MigrationTable.json (created by IntuneManagement export).
|
||||
"""
|
||||
@@ -21,7 +21,19 @@ from typing import Any, Optional
|
||||
|
||||
OUTPUT_FILE = "settings-report.csv"
|
||||
|
||||
BASE_FIELDNAMES = ["Policy", "Setting", "Value"]
|
||||
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 = {
|
||||
@@ -83,6 +95,51 @@ def _choice_label(catalog: dict[str, Any], setting_id: str, value_id: str) -> st
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -151,7 +208,7 @@ def _summarize_assignments(policy: dict, groups: dict[str, str]) -> dict[str, st
|
||||
# Settings Catalog recursive walker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str,
|
||||
parent: str = "") -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
otype = si.get("@odata.type", "")
|
||||
@@ -162,17 +219,19 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
|
||||
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({"Policy": policy, "Setting": name, "Value": 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({"Policy": policy, "Setting": name, "Value": value})
|
||||
rows.append({**base_row, "Setting": name, "Value": value})
|
||||
|
||||
elif "SimpleSettingCollectionInstance" in otype:
|
||||
vals = [
|
||||
@@ -180,14 +239,14 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
for v in si.get("simpleSettingCollectionValue", [])
|
||||
]
|
||||
if vals:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": "; ".join(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({"Policy": policy, "Setting": name, "Value": "; ".join(vals)})
|
||||
rows.append({**base_row, "Setting": name, "Value": "; ".join(vals)})
|
||||
|
||||
elif "GroupSettingCollectionInstance" in otype:
|
||||
for group in si.get("groupSettingCollectionValue", []):
|
||||
@@ -195,7 +254,7 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
|
||||
for child in children:
|
||||
if isinstance(child, dict):
|
||||
rows.extend(_walk(child, catalog, policy, parent=name))
|
||||
rows.extend(_walk(child, catalog, policy, platform, parent=name))
|
||||
|
||||
return rows
|
||||
|
||||
@@ -223,10 +282,11 @@ def process_settings_catalog(root: Path, catalog: dict[str, Any],
|
||||
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):
|
||||
for row in _walk(si, catalog, policy_name, platform):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
@@ -248,6 +308,7 @@ def process_flat_category(root: Path, category: str,
|
||||
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:
|
||||
@@ -258,7 +319,7 @@ def process_flat_category(root: Path, category: str,
|
||||
value_str = value_str[:497] + "..."
|
||||
else:
|
||||
value_str = str(value)
|
||||
row = {"Policy": policy_name, "Setting": key, "Value": value_str}
|
||||
row = {"Policy": policy_name, "Platform": platform, "Setting": key, "Value": value_str}
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
Reference in New Issue
Block a user