Files
tomas.kracmar d3e0769799 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
2026-06-14 15:24:42 +02:00

174 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Generate a policy assignment inventory CSV from Intune backup JSON files.
Walks every JSON file under the backup root and emits one row per assignment
target (or one row per unassigned/not-exported object).
Output columns: PolicyType, ObjectName, ObjectType, AssignmentState,
Intent, AssignmentTarget, TargetType, AssignmentFilter,
FilterType, SourceFile
"""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
from typing import Iterator
_GROUP_TARGET_TYPES = {
"#microsoft.graph.groupAssignmentTarget",
"#microsoft.graph.exclusionGroupAssignmentTarget",
}
_EXCLUDED_DIRS = {"reports", "__archive__"}
FIELDNAMES = [
"PolicyType",
"ObjectName",
"ObjectType",
"AssignmentState",
"Intent",
"AssignmentTarget",
"TargetType",
"AssignmentFilter",
"FilterType",
"SourceFile",
]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--root", required=True,
help="Path to backup root (e.g. tenant-state/intune).")
p.add_argument("--output", default="assignment-report.csv",
help="Output CSV path (default: assignment-report.csv).")
p.add_argument("--policy-type", action="append", default=[],
help="Filter to specific top-level folder names (repeat or comma-separate).")
return p.parse_args()
def _safe(value: object) -> str:
return "" if value is None else str(value).strip()
def _resolve_target(target: dict) -> tuple[str, str]:
"""Returns (display_name, target_type_short)."""
ttype = _safe(target.get("@odata.type"))
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
return "All devices", ttype
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
return "All users", ttype
if ttype in _GROUP_TARGET_TYPES:
name = (target.get("groupDisplayName") or target.get("groupName")
or target.get("groupId") or "Unresolved group")
return _safe(name), ttype
return (_safe(target.get("groupDisplayName") or target.get("displayName")
or target.get("id")) or "Unknown target", ttype)
def _infer_intent(assignment: dict, target_type: str) -> str:
if "exclusion" in target_type.lower():
return "Exclude"
explicit = _safe(assignment.get("intent")).lower()
if explicit in {"exclude"}:
return "Exclude"
return "Include"
def _iter_rows(root: Path, policy_type_filter: set[str]) -> Iterator[dict]:
for path in sorted(root.rglob("*.json")):
try:
rel = path.relative_to(root)
except ValueError:
continue
if any(part in _EXCLUDED_DIRS for part in rel.parts):
continue
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(payload, dict):
continue
policy_type = rel.parts[0] if rel.parts else ""
if policy_type_filter and policy_type.lower() not in policy_type_filter:
continue
object_name = (_safe(payload.get("displayName")) or _safe(payload.get("name"))
or path.stem.split("__")[0])
object_type = _safe(payload.get("@odata.type"))
source = rel.as_posix()
base = {
"PolicyType": policy_type,
"ObjectName": object_name,
"ObjectType": object_type,
"SourceFile": source,
}
assignments = payload.get("assignments")
if not isinstance(assignments, list):
yield {**base, "AssignmentState": "NotExported", "Intent": "",
"AssignmentTarget": "Not exported in backup", "TargetType": "",
"AssignmentFilter": "", "FilterType": ""}
continue
valid = [a for a in assignments if isinstance(a, dict)]
if not valid:
yield {**base, "AssignmentState": "Unassigned", "Intent": "",
"AssignmentTarget": "No assignments", "TargetType": "",
"AssignmentFilter": "", "FilterType": ""}
continue
for assignment in valid:
target = assignment.get("target") or {}
target_name, target_type = _resolve_target(target)
intent = _infer_intent(assignment, target_type)
yield {
**base,
"AssignmentState": "Assigned",
"Intent": intent,
"AssignmentTarget": target_name,
"TargetType": target_type,
"AssignmentFilter": _safe(target.get("deviceAndAppManagementAssignmentFilterId")),
"FilterType": _safe(target.get("deviceAndAppManagementAssignmentFilterType")),
}
def main() -> None:
args = parse_args()
root = Path(args.root).resolve()
out_path = Path(args.output)
if not root.exists():
raise SystemExit(f"Backup root not found: {root}")
policy_type_filter: set[str] = set()
for raw in args.policy_type:
for part in raw.split(","):
v = part.strip().lower()
if v:
policy_type_filter.add(v)
rows = sorted(
_iter_rows(root, policy_type_filter),
key=lambda r: (r["PolicyType"].lower(), r["ObjectName"].lower(),
r["AssignmentState"], r["Intent"].lower(),
r["AssignmentTarget"].lower()),
)
out_path.parent.mkdir(parents=True, exist_ok=True)
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()