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,173 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user