From 122aa2d4e3656cefee216018d4701bda6596da74 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Mon, 22 Jun 2026 11:56:55 +0200 Subject: [PATCH] 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 --- .gitignore | 4 +- AGENTS.md | 2 +- CHANGELOG_macOS_IntuneToolkit.md | 17 +++++++ Extensions/MSALAuthentication.psm1 | 45 +++++++++++------ Extensions/MSGraph.psm1 | 4 +- Scripts/Export-SettingsReport.py | 81 ++++++++++++++++++++++++++---- 6 files changed, 122 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index bba9465..344f3af 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,8 @@ Exporting */ *.backup/ *.backup -# Graph metadata cache +# Graph metadata cache (now stored in the platform-specific data folder) GraphMetaData.xml -%LOCALAPPDATA%/ -*%LOCALAPPDATA%* CloudAPIPowerShellManagement/ # Local application settings (contains secrets on non-macOS platforms) diff --git a/AGENTS.md b/AGENTS.md index 1545257..1790b91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ The launcher caches tenant display names in `Settings.json` so the TUI can show |---|---| | `Start-IntuneToolkit.ps1` | Unified reverse-numbered `fzf`/numbered menu; remembers tenants; launches all other tools. | | `Scripts/Start-HeadlessIntune.ps1` | Single-action wrapper (`Export`, `Import`, `DeployCISBaseline`, `GenerateReports`) with optional interactive TUI. | -| `Scripts/Export-SettingsReport.py` | Generate a flat CSV of policy settings/values. Settings Catalog names are resolved from `configurationSettings.json` (auto-exported with Settings Catalog). | +| `Scripts/Export-SettingsReport.py` | Generate a flat CSV of policy settings/values. Includes a `Platform` column and resolves Settings Catalog names from `configurationSettings.json` (auto-exported with Settings Catalog). | | `Scripts/Export-Policies.ps1` | Export policies to JSON. | | `Scripts/Import-Policies.ps1` | Import policies from JSON. | | `Scripts/Initialize-IntuneAuth.ps1` | One-time Entra app setup; also supports `-RotateSecret`, `-Delete`, `-DeleteApp`. | diff --git a/CHANGELOG_macOS_IntuneToolkit.md b/CHANGELOG_macOS_IntuneToolkit.md index b032a36..09e58f7 100644 --- a/CHANGELOG_macOS_IntuneToolkit.md +++ b/CHANGELOG_macOS_IntuneToolkit.md @@ -9,6 +9,23 @@ - This lets `Scripts/Export-SettingsReport.py` resolve `settingDefinitionId` values to the human-readable names shown in the Intune portal without any manual steps. - Errors during definition export are logged but do not fail the policy export. +- **`Scripts/Export-SettingsReport.py`** + - New `Platform` column between `Policy` and `Setting`. + - For Settings Catalog, platform is read from the `platforms` field (e.g. `macOS`, `windows10`). + - For legacy policies, platform is inferred from `platform`/`platformType` or from `@odata.type` (e.g. `#microsoft.graph.iosCompliancePolicy` → `iOS`). + +### Fixed +- **`Extensions/MSGraph.psm1`** + - `Get-GraphMetaData` now stores `GraphMetaData.xml` in the cross-platform data folder (`Get-CloudApiDataFolder`) instead of the literal Windows path `%LOCALAPPDATA%\CloudAPIPowerShellManagement\GraphMetaData.xml`. + - Removed the stray `%LOCALAPPDATA%\CloudAPIPowerShellManagement` folder from the repository and moved the existing `GraphMetaData.xml` to the correct macOS app-data location. + +- **`Extensions/MSALAuthentication.psm1`** + - On non-Windows platforms the toolkit now skips `TokenCacheHelperEx` compilation with an informational log instead of throwing a `System.Security.Cryptography.ProtectedData.dll` error. + - Applied the same skip to the legacy `Add-MSALPrereq_old` function for consistency. + +- **`.gitignore`** + - Removed the literal `%LOCALAPPDATA%` ignore patterns; kept `GraphMetaData.xml` and `CloudAPIPowerShellManagement/` ignores as safeguards. + ### Modified - **`AGENTS.md`** - Added `Scripts/Export-SettingsReport.py` to the main entry points table and noted the automatic Settings Catalog name resolution. diff --git a/Extensions/MSALAuthentication.psm1 b/Extensions/MSALAuthentication.psm1 index e158c2a..cbcfb90 100644 --- a/Extensions/MSALAuthentication.psm1 +++ b/Extensions/MSALAuthentication.psm1 @@ -554,7 +554,12 @@ function Add-MSALPrereq } } - if (-not ("TokenCacheHelperEx" -as [type])) + if (-not (Test-IsWindowsPlatform)) + { + $global:SkipTokenCacheHelperEx = $true + Write-Log "Token cache serialization is only supported on Windows. Skipping TokenCacheHelperEx." + } + elseif (-not ("TokenCacheHelperEx" -as [type])) { [System.Collections.Generic.List[string]] $RequiredAssemblies = New-Object System.Collections.Generic.List[string] @@ -723,23 +728,31 @@ function Add-MSALPrereq_old $script:msalFile = $msalPath } - $RequiredAssemblies.Add('System.Security.dll') - $RequiredAssemblies.Add('mscorlib.dll') - if($PSVersionTable.PSVersion.Major -ge 7) - { - $RequiredAssemblies.Add('System.Security.Cryptography.ProtectedData.dll') - } - - $RequiredAssemblies.Add('System.Threading.dll') - - try - { - Add-Type -Path ($global:AppRootFolder + "\CS\TokenCacheHelperEx.cs") -ReferencedAssemblies $RequiredAssemblies - } - catch + if (-not (Test-IsWindowsPlatform)) { $global:SkipTokenCacheHelperEx = $true - Write-LogError "Failed to compile TokenCacheHelperEx. The access token will not be cached. Check write access to the CS folder and ASR policies" $_.Exception + Write-Log "Token cache serialization is only supported on Windows. Skipping TokenCacheHelperEx." + } + else + { + $RequiredAssemblies.Add('System.Security.dll') + $RequiredAssemblies.Add('mscorlib.dll') + if($PSVersionTable.PSVersion.Major -ge 7) + { + $RequiredAssemblies.Add('System.Security.Cryptography.ProtectedData.dll') + } + + $RequiredAssemblies.Add('System.Threading.dll') + + try + { + Add-Type -Path ($global:AppRootFolder + "\CS\TokenCacheHelperEx.cs") -ReferencedAssemblies $RequiredAssemblies + } + catch + { + $global:SkipTokenCacheHelperEx = $true + Write-LogError "Failed to compile TokenCacheHelperEx. The access token will not be cached. Check write access to the CS folder and ASR policies" $_.Exception + } } if(Test-IsWindowsPlatform) { diff --git a/Extensions/MSGraph.psm1 b/Extensions/MSGraph.psm1 index c307c22..f5ece4d 100644 --- a/Extensions/MSGraph.psm1 +++ b/Extensions/MSGraph.psm1 @@ -1186,7 +1186,9 @@ function Get-GraphMetaData # There also no other version information in response headers. Use file date to update every week Write-Log "Load Graph MetaData file" $url = "https://graph.microsoft.com/beta/`$metadata" - $fileFullPath = [Environment]::ExpandEnvironmentVariables("%LOCALAPPDATA%\CloudAPIPowerShellManagement\GraphMetaData.xml") + $dataFolder = if(Get-Command Get-CloudApiDataFolder -ErrorAction SilentlyContinue) { Get-CloudApiDataFolder } else { [Environment]::ExpandEnvironmentVariables("%LOCALAPPDATA%\macOS_IntuneManagement") } + [void][IO.Directory]::CreateDirectory($dataFolder) + $fileFullPath = Join-Path $dataFolder "GraphMetaData.xml" $fi = [IO.FileInfo]$fileFullPath $maxAge = (Get-Date).AddDays(-14) if($fi.Exists -and ($fi.LastWriteTime -gt $maxAge -or $fi.CreationTime -gt $maxAge)) diff --git a/Scripts/Export-SettingsReport.py b/Scripts/Export-SettingsReport.py index 78574b3..cf71653 100644 --- a/Scripts/Export-SettingsReport.py +++ b/Scripts/Export-SettingsReport.py @@ -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