release: v4.1.0 — restructure entry points, add CIS baselines, reporting tools and fzf hints
- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root; Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/ - Add AGENTS.md with project architecture, entry points, and security notes - Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts - Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport, Export-ObjectInventoryReport) and CA wizard helpers - Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath, and optimized group loading - Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry - Update Extensions for Settings Catalog definition auto-export - Update README with v4.1.0, new entry points and script catalog - Bump VERSION to 4.1.0 - Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports, Settings.json and IntuneManagement.log
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
#!/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, 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", "Setting", "Value"]
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
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] = []
|
||||
|
||||
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})
|
||||
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})
|
||||
|
||||
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({"Policy": policy, "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)})
|
||||
|
||||
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, 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
|
||||
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):
|
||||
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
|
||||
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, "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()
|
||||
Reference in New Issue
Block a user