132 Commits
Author SHA1 Message Date
tomas.kracmarandClaude Sonnet 5 60a655e6d4 refactor(tui): reorder main menu into logical groups
Menu had grown by prepending each new feature to the top, so items
had no relation to their neighbors. Grouped into Export/Import, Bulk
operations, Baselines & compliance, Reporting, Tenant & auth admin,
with Exit last. Item numbers (used for dispatch) unchanged, only
display order moved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 18:51:18 +02:00
tomas.kracmarandClaude Sonnet 5 81c5e96fc7 feat(tui): add fzf-based folder browser for export/import path prompts
Read-Host has no tab-completion (that's a top-level PSReadLine feature,
not available to script prompts), so reuse the fzf dependency already
required for menus to browse folders interactively instead: navigate
into subfolders, go up with .., confirm current folder, or type a path
manually. Falls back to plain Read-Host when fzf isn't installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 11:53:05 +02:00
tomas.kracmarandClaude Sonnet 5 30170a1909 fix(tui): stop re-asking action when launcher already picked Export/Import
Same missing-passthrough bug as the earlier tenant re-prompt fix, this time
for -Action: Start-IntuneManagementTui.ps1 always showed the
Export/Import/DeployCISBaseline/GenerateReports picker even when the
launcher's menu items 1/2 had already decided it. Launcher now passes
-Action through, TUI skips the prompt when supplied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 11:37:35 +02:00
tomas.kracmarandClaude Sonnet 5 ae71f8458d feat(toolkit): add bulk delete tool, fix TUI exit hang and tenant re-prompt
- New Scripts/Bulk-DeletePolicies.ps1: type-DELETE confirmation, -WhatIf dry-run
- Fix infinite loop in Start-IntuneToolkit.ps1 when fzf missing (numbered-menu
  Exit returned "EXIT" but check only matched fzf's "[Exit]" string)
- Start-IntuneManagementTui.ps1 no longer re-prompts for TenantId when the
  launcher already resolved one; also fixes misleading "regex" wording on
  the Name filter prompt (it's a literal substring match)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 11:31:22 +02:00
tomas.kracmarandClaude Sonnet 4.6 2f0d937ca1 fix(ca-exporter): resolve AuthenticationFlows TransferMethods and surface in Excel
Raw object dump replaced with TransferMethods extraction (falls back to
AdditionalProperties for older module versions). Field added to the
Conditions section of Excel detail sheets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 09:53:42 +02:00
tomas.kracmarandClaude Sonnet 4.6 b10258c5b4 fix(ca-exporter): pre-fetch auth context class references to fix ID resolution
Replaced per-policy Graph API calls with a single pre-fetch of all
authentication context class references into a hashtable, consistent
with every other lookup in the script. Also fixes silent ID drops when
DisplayName was missing — GetOrDefault now falls back to the raw ID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 09:49:48 +02:00
tomas.kracmar 6bf7345eb7 release: v4.2.0 — Entra directory role membership export
- Add Scripts/Export-EntraRoleMembership.ps1 to export active + PIM-eligible
  Entra directory role memberships (with group expansion) to CSV.
- Wire the new script into Start-IntuneToolkit.ps1 as menu item 20.
- Document the new script in README.md and AGENTS.md.
- Bump VERSION to 4.2.0 and add CHANGELOG entry.
- Harden .gitignore against local operational artifacts (accounts/ folder,
  deploy.sh, restart_gateways.sh).
2026-07-07 14:57:02 +02:00
tomas.kracmar 90209a7ec4 feat(reporting): expand settings report and add CA documentation
- Export-SettingsReport.py: add coverage for Compliance V2, Endpoint
  Security, Device Management Intents, ADMX, scripts (base64 decoded),
  App Protection/Configuration, updates, enrollment, Autopilot, W365,
  filters, and more; add OMA-URI and customSettings expansion
- Start-IntuneToolkit.ps1: add menu item 19 for Conditional Access
  documentation
- Add Invoke-ConditionalAccessDocumentation.ps1 for CA policy docs
  (CSV/Excel)
- .gitignore: exclude CA documentation CSV/XLSX outputs
- CHANGELOG: document the new reporting capabilities
2026-06-29 15:12:37 +02:00
tomas.kracmar 122aa2d4e3 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
2026-06-22 11:56:55 +02:00
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
tomas.kracmar e333af978c Bump version to 4.1.0 2026-04-16 15:43:34 +02:00
tomas.kracmar 70679cba48 v4.0.1: per-user app naming, auth deletion, TUI onboarding flow, PIM docs 2026-04-16 15:40:33 +02:00
tomas.kracmar 1ff059342f chore: bump VERSION to 4.0.1 2026-04-16 11:36:09 +02:00
tomas.kracmar ab6817f59b feat(ui): detect missing fzf and show installation hints 2026-04-16 11:34:03 +02:00
tomas.kracmar 15210313cd chore: clean ignored cache, refresh README, add VERSION 4.0.0 2026-04-16 11:02:54 +02:00
tomas.kracmar 18fc5190d8 feat(launcher): add 'Refresh tenant names' menu option
- Menu entry 13 re-queries Graph /organization for every saved tenant
- Updates cached TenantName values in Settings.json
- Refreshes the active tenant display in the menu header
2026-04-14 19:51:16 +02:00
tomas.kracmar e8ad3f6b96 feat(launcher): tenant picker with name resolution in TUI
- Start-IntuneToolkit.ps1 now shows a tenant picker as the first step
  when -TenantId is not provided.
- Lists saved tenants from Settings.json, attempting to display cached
  tenant names alongside GUIDs.
- Offers '[+ Onboard new tenant]' option for entering new tenants.
- Automatically resolves unknown tenant names via Graph /organization
  and caches them back to Settings.json for future use.
2026-04-14 19:43:54 +02:00
tomas.kracmar 6703625c00 fix(rename): guard Add prefix against double-prefixing
Skip objects whose displayName or description already starts with
the requested prefix. This makes Add prefix idempotent.
2026-04-14 19:02:06 +02:00
tomas.kracmar cd1c004f4b fix(rename): add retry logic for transient 5xx/429 errors
Settings Catalog and other Graph endpoints occasionally return 500
InternalServerError on PATCH. Retry up to 3 times with a 2s delay
to improve reliability for bulk rename operations.
2026-04-14 18:58:20 +02:00
tomas.kracmar 07c25e897a fix(assignments): use bulk /assign endpoint for removals on all types
Settings Catalog and other bulk-assign types do not support DELETE on
individual assignments. Removal now reloads existing assignments,
filters out selected targets, sanitizes remaining payloads, and
re-POSTs to <API>/<id>/assign. This mirrors the add flow and works
universally across all supported object types.
2026-04-14 18:50:02 +02:00
tomas.kracmar 814c1d116c fix(tui): remove space:toggle binding from fzf to allow searching with spaces
--bind=space:toggle prevented typing spaces in search queries.
fzf --multi already supports Tab for selection by default.
2026-04-14 18:44:54 +02:00
tomas.kracmar 33e1118cc6 fix: restore .value access when using -AllPages on group queries
Invoke-GraphRequest with -AllPages returns the full response object
with accumulated items in .value, not a flat array.
2026-04-14 18:39:50 +02:00
tomas.kracmar 77f7372d44 fix: fetch all groups via -AllPages for large tenants
Replaces default 100-item page limits and =999 workarounds
with -AllPages on group queries across assignment and baseline scripts.
Enables full fzf inline search/filter for group selection.
2026-04-14 18:36:23 +02:00
tomas.kracmar 773ab12901 fix(launcher): strip unsupported auth params when calling Initialize-IntuneAuth 2026-04-14 18:27:40 +02:00
tomas.kracmar 68a5af1af3 chore: ignore GraphMetaData.xml and CloudAPIPowerShellManagement cache files 2026-04-14 15:12:52 +02:00
tomas.kracmar e13d14edcb feat(toolkit): complete macOS Intune Toolkit v1
Core enhancements:
- Expanded default export/import scope to ~45 object types including DeviceManagementIntents
- Added -AllPages pagination support across Graph queries for large tenants
- Invoke-GraphRequest now throws on 4xx/5xx instead of silently returning null
- Added macOS Keychain fallback for secret retrieval in headless auth flow
- Added NameSearchPattern/NameReplacePattern mutation support through export/import forms

New toolkit scripts:
- Bulk-AppAssignment.ps1: bulk-assign apps to groups/All Users/All Devices
- Bulk-AssignmentManager.ps1: add/remove assignments for any policy type with correct @odata.type
- Backup-Restore-Assignments.ps1: JSON backup with cross-tenant group resolution
- Export-AssignmentsToCsv.ps1: CSV/Markdown documentation output
- Bulk-RenamePolicies.ps1: regex search/replace and prefix mutations
- Bulk-DeviceOperations.ps1: delete/retire/wipe/lock/sync with -WhatIf safeguards
- Start-IntuneManagementTui.ps1: interactive terminal UI for headless operations
- Create-IntuneManagementApp.ps1: helper for app registration setup

Updated existing scripts:
- Export-Policies.ps1 / Import-Policies.ps1: wired mutation params through
- Start-HeadlessIntune.ps1: integrated TUI and new parameter forwarding
2026-04-14 15:11:09 +02:00
tomas.kracmar 0ddd21ab14 feat(launcher): add baseline deployer to unified menu
- Adds 'Deploy baseline' and 'Deploy baseline (dry-run / WhatIf)' to Start-IntuneToolkit.ps1
- Ensures WhatIf flag is cleared between menu iterations
2026-04-14 15:05:15 +02:00
tomas.kracmar c4b8f4aaf6 feat(baseline): declarative Intune baseline deployer
- Add Deploy-IntuneBaseline.ps1 for YAML-driven policy + assignment deployment
- Add ConvertTo-IntuneBaseline.ps1 to convert export folders to baseline manifests
- Add example OpenIntuneBaseline YAML in Baselines/
- Supports mutations, group auto-creation, idempotency, and WhatIf mode
2026-04-14 14:59:29 +02:00
tomas.kracmar 87b7af25a7 feat(auth): sync full Graph permission set and patch existing apps
- Unified required Microsoft Graph app roles in Initialize-IntuneAuth.ps1
- Added permission patching for existing app registrations
- Logs the change and operations for audit
2026-04-14 12:15:14 +02:00
tomas.kracmar 9dace83cff Make browser auth app id optional 2026-04-08 19:10:45 +02:00
tomas.kracmar 239e3ec16e Add browser auth for headless runs 2026-04-08 16:09:30 +02:00
tomas.kracmar c803a00df7 Strip legacy UI and keep headless runtime 2026-04-08 15:35:04 +02:00
tomas.kracmar 8fe71c0078 Add headless macOS CLI workflow 2026-04-08 15:18:32 +02:00
Mikael KarlssonandGitHub faffa95d8a Merge pull request #363 from Micke-K/Development
3.10.1 Release
2025-09-14 14:19:01 +10:00
Mikael Karlsson 485a9de855 3.10.1 2025-09-14 14:15:15 +10:00
Mikael Karlsson 951b583dd2 3.10.0.11
Added full support for BIOS Config polices eg export and edit file + documentation
2025-08-09 13:30:51 +10:00
Mikael Karlsson f27175d543 3.10.0.10
Added support for BIOS Confiuration policies
2025-08-06 20:19:13 +10:00
Mikael Karlsson 1ab13bf2dd 3.10.0.9
Fixed issue with Not Configured for Custom Compliance script.
Added option to skip date when generating MD document.
2025-08-03 20:42:46 +10:00
Mikael Karlsson 8952e2894a 3.10.0.8
Fix renaming Settings Catalog policies
2025-08-03 20:12:34 +10:00
Mikael KarlssonandGitHub a0bac61ba4 Merge pull request #339 from MrR0bert/FixExportDirectoryNaming
Add Trim() to sanitize the organization displayName property
2025-08-03 19:12:08 +10:00
Mikael KarlssonandGitHub 8fab983b25 Merge pull request #347 from brefra/Fix-Upload
Fix .intunewin uploads when using powershell 7.4+
2025-08-03 19:06:36 +10:00
Frank 6f6eba6959 Fix content upload at powershell 7.4+ 2025-07-29 21:08:56 +02:00
Mikael Karlsson b69cc227a4 3.10.0.7
Fixed issue with ADMX import and different enum format
Fixed issue with skipping Not Configured for Compliance Policies
Added support for documenting Compliance Policy V2 (Linux)
2025-07-28 21:11:17 +10:00
Mikael Karlsson 4253901249 3.10.0.6
Fixed import order for Compliance Scripts
2025-07-16 04:23:32 +10:00
Robert Kooistra cfb0e5f6e4 Moved trim of org displayName to the moment it is loaded instead of where it's used 2025-06-24 10:07:17 +02:00
MrR0b3rt 125d2e2b44 Forgot brackets before calling Trim() 2025-06-23 15:10:33 +02:00
MrR0b3rt 95c38a2b3d Add Trim() to sanitize the organization displayName property 2025-06-23 14:29:07 +02:00
Mikael Karlsson 9586ffb3fa 3.10.0.5
Removed deviceStates property for Conditional Access policy import. The property is depricated
2025-06-17 02:04:35 +10:00
Mikael Karlsson 5984acfca0 3.10.0.4
Fixed issue with Word Interop not loading
Added support for PDF output
2025-05-25 21:07:57 +10:00
Mikael Karlsson e1328ac7dd 3.10.0.3
Added documenting for file content eg MacOS Custom Settings
2025-05-03 15:42:03 +10:00
Mikael Karlsson 968fb4866c 3.10.0.2
Fixed issue with importing Security Baseline 24H2.
2025-04-19 14:53:57 +10:00
Mikael Karlsson 2169e91c9c 3.10.0
Fixed bad trimming
2025-03-16 20:59:19 +11:00
Mikael Karlsson ad5d6df95e 3.10.0
Added additional logging
2025-03-16 20:59:01 +11:00
Mikael Karlsson 9232769d5b Fixed Compare Issues
Fixed issues with Compare and Settings Catalog
2025-03-09 19:57:41 +11:00
Mikael Karlsson 06ab2c2023 Paging issues
Device settings does not return next page so get top 500
Found a rare issue where Settings Catalog could return next page after all items were returned. This caused the app to crash when scrolling the list.
2025-03-01 12:08:16 +11:00
Mikael Karlsson 8601a5b38e Initial 3.10.0 upload 2025-02-22 21:52:13 +11:00
Mikael KarlssonandGitHub 17e9b786be Merge pull request #296 from Mykhailo-Roit/master
feat: replace_with_assignments import type
2025-01-12 17:20:36 +11:00
Mikael KarlssonandGitHub e2c40b0a67 Merge pull request #290 from Systems-Liam/patch-1
Update DocumentationWordOptions.xaml
2025-01-12 17:17:14 +11:00
Mykhailo-Roit 381967c8cf fix conditions 2025-01-09 15:40:21 +02:00
Mykhailo-Roit 28022615a1 feat: add tooltip description for 'Replace with assignments' option in import forms 2025-01-08 18:58:36 +02:00
Mykhailo-Roit ff539f9ec1 feat: replace_with_assignments import type 2025-01-08 18:45:48 +02:00
LiamandGitHub 7bab923a1f Update DocumentationWordOptions.xaml
Update typo on line 251.  Ducument to Document
2025-01-04 21:36:09 +00:00
Mikael Karlsson 4deea9a051 Updated release date 2024-10-12 12:54:40 +11:00
Mikael Karlsson c6fcf58d4d 3.9.8 2024-10-12 12:53:24 +11:00
Mikael Karlsson 5326bd5525 3.9.7 2024-06-27 21:00:44 +10:00
Mikael KarlssonandGitHub b7e04bc820 Merge pull request #220 from ee61re/master
various documentation updates
2024-04-30 07:35:16 +10:00
Rob EllisandGitHub 670f47ea3f Merge branch 'Micke-K:master' into master 2024-04-28 20:06:56 +01:00
Rob EllisandGitHub 6d86c3e91e Update MSALInfo.md
typos
2024-04-28 20:04:13 +01:00
Rob EllisandGitHub f41827f41a Update Documentation.md
typos
2024-04-28 20:01:54 +01:00
Rob EllisandGitHub b56a226c3e Update ADMXImport.md
typos
2024-04-28 19:53:07 +01:00
Rob EllisandGitHub 8ff175534d Update README.md
Typos
2024-04-28 19:37:24 +01:00
Mikael KarlssonandGitHub 6991cae5ae Merge pull request #219 from ee61re/master
Update CompareForm.xaml
2024-04-28 10:19:40 +10:00
Rob EllisandGitHub 3ce1be57ae Update CompareForm.xaml
Fixed typos
2024-04-27 14:58:53 +01:00
Mikael Karlsson c16f85a299 3.9.6 2024-04-22 21:48:16 +10:00
Mikael Karlsson 47968b4219 3.9.5 2024-01-24 21:25:53 +11:00
Mikael Karlsson 6ac211a2bf 3.9.4 2023-12-18 19:37:17 +11:00
Mikael Karlsson 83c845fc33 3.9.3 Fix 2023-12-11 19:00:23 +11:00
Mikael Karlsson f5613442bd 3.9.3 2023-12-11 18:58:13 +11:00
Mikael Karlsson ab7b062946 3.9.2 2023-10-17 20:34:44 +11:00
Mikael Karlsson ea3af64316 3.9.1 2023-08-30 20:07:18 +10:00
Mikael Karlsson e6ec048df0 3.9.0 Module
Added version in the module file
2023-05-04 23:42:37 +10:00
Mikael Karlsson 0fd868ff70 3.9.0 2023-05-04 22:00:12 +10:00
Mikael Karlsson ece28a649f 3.8.1 2023-01-26 22:29:21 +11:00
Mikael Karlsson 897309e48e 3.7.4 2022-11-22 21:54:51 +11:00
Mikael Karlsson 2dc5eca089 Merge branch 'master' of https://github.com/Micke-K/IntuneManagement 2022-10-25 20:28:23 +11:00
Mikael Karlsson 02c162efd3 Updated the version 2022-10-25 20:27:22 +11:00
Mikael KarlssonandGitHub aae21c3960 Update ReleaseNotes.md 2022-10-24 23:41:21 +11:00
Mikael Karlsson 103b1c2e66 3.7.3 2022-10-24 19:19:30 +11:00
Mikael Karlsson d7f2d32771 3.7.2 2022-10-11 20:35:20 +11:00
Mikael KarlssonandGitHub 9c9e46d315 Merge pull request #86 from cstaubli/master
Find certificate by thumbprint in store
2022-09-04 18:58:41 +10:00
Christian Staubli e980b412d0 Find certificate by thumbprint in store 2022-09-02 11:31:26 +02:00
Mikael Karlsson 4d3d8a3060 3.7.1 2022-08-08 21:54:53 +10:00
Mikael Karlsson 37965cb290 3.7.0 2022-08-02 22:22:54 +10:00
Mikael Karlsson a60b83ede9 3.6.0 2022-06-29 22:56:52 +10:00
Mikael Karlsson 4571341763 3.5.0 2022-04-26 21:49:54 +10:00
Mikael Karlsson 46435b5717 3.4.0
New features and fixes
2022-03-01 17:54:14 +11:00
Mikael Karlsson 5534eb368e 3.3.3
Fixed displayName missing bug
2021-12-14 19:15:27 +11:00
Mikael Karlsson bc48d14176 3.3.2 2021-12-14 02:04:15 +11:00
Mikael Karlsson 2b9efd5c85 3.3.1 Beta 2021-10-28 21:30:21 +11:00
Mikael Karlsson 4add87884a 3.3.0
Beta release
2021-10-17 14:02:08 +11:00
Mikael Karlsson 5976b0bffd 3.2.3 2021-10-07 20:06:31 +11:00
Mikael Karlsson b852e9163c 3.2.2 2021-09-23 18:12:33 +10:00
Mikael Karlsson 58abeeb9bd 3.2.1 2021-09-04 10:19:42 +10:00
Mikael Karlsson 95bd8fc07c 3.2.0 2021-08-15 20:54:37 +10:00
Mikael Karlsson d3a3ddef79 3.1.8 2021-07-18 16:57:46 +10:00
Mikael Karlsson 096ffc6cf2 Link update 2021-07-12 20:44:08 +10:00
Mikael Karlsson fb46337f98 3.1.7 2021-07-12 20:24:27 +10:00
Mikael Karlsson 06688e7193 3.1.6 2021-07-07 21:50:48 +10:00
Mikael Karlsson a0692159b5 3.1.5 2021-07-06 09:28:06 +10:00
Mikael Karlsson 791074ffcd Changed version 2021-07-06 09:06:11 +10:00
Mikael Karlsson 77034460bb 3.1.4
Fixed Admin Templates import
2021-07-06 09:04:56 +10:00
Mikael Karlsson e66814055d 3.1.3
Bulk Compare, Bulk Copy + bug fixes
2021-07-05 18:25:33 +10:00
Mikael Karlsson 1556c447c5 3.1.2
Support for deleting profiles/policies
2021-06-20 19:25:27 +10:00
Mikael Karlsson a495b0f0df 3.1.1 fixes
Fix issue with getting user information calling the ME API with full ODATA info
2021-06-16 12:14:01 +10:00
Mikael Karlsson 18533494b2 3.1 changes 2021-06-08 19:02:25 +10:00
Micke c7f8cbe760 Version 3 Beta 1 - Complete re-write 2021-04-13 17:57:12 +10:00
Mikael KarlssonandGitHub 2dfaf1bfba Update README.md 2021-02-04 09:16:52 +11:00
Mikael KarlssonandGitHub 3545af455d Update README.md 2021-02-03 13:04:28 +11:00
Micke b4c737dd6d Removed support for AzureRM
Az module is required for Native Azure objects (Conditional access, Azure branding and MDM/MAM settings)
Fixed limit on Conditional Access objects
Remove properties before import (date etc.)
Added WIP policies
Added support for installing Intune module for user only
2021-02-03 12:50:22 +11:00
Mikael KarlssonandGitHub bcc178ac99 Update README.md 2019-07-03 07:03:57 +10:00
Micke 0cbfc3ae87 Fixed issue with installing Intune module
Added support for viewing object info
2019-07-03 06:52:59 +10:00
Mikael KarlssonandGitHub 7d357af03e Update README.md 2019-06-13 11:59:21 +10:00
Mikael KarlssonandGitHub 3ae365cb4d Add files via upload 2019-06-10 19:14:05 +10:00
Mikael KarlssonandGitHub b6eb4875ec Add files via upload 2019-06-10 19:13:33 +10:00
Mikael KarlssonandGitHub 15d7c4a423 Add files via upload 2019-06-10 19:12:11 +10:00
Mikael KarlssonandGitHub a649c6ef49 Update README.md 2019-06-10 13:54:49 +10:00
Mikael KarlssonandGitHub bc19db7bb8 Update README.md 2019-06-10 13:45:37 +10:00
Mikael KarlssonandGitHub d61f9de2af Update README.md 2019-06-10 13:45:09 +10:00
Mikael KarlssonandGitHub 337ff16f49 Create LICENSE 2019-06-10 13:44:25 +10:00
Mikael KarlssonandGitHub 816b20caa8 Update README.md 2019-06-10 13:32:45 +10:00
Mikael KarlssonandGitHub 1143fcc0c8 Add files via upload 2019-06-10 13:07:59 +10:00
Mikael KarlssonandGitHub c820c9feb7 Initial commit 2019-06-10 12:11:54 +10:00
58 changed files with 31276 additions and 1 deletions
+43
View File
@@ -0,0 +1,43 @@
# Editor / OS
.vs/
.vscode/
.DS_Store
# Git
.git/
# PowerShell / logs
/*.Log
/*.Lo_
IntuneManagement.log
# Local exports, backups and generated reports
/*.csv
/*.zip
/*.new
/*.old
/Extensions/*.zip
Exporting */
*.backup/
*.backup
Scripts/ConditionalAccessDocumentation.csv
Scripts/ConditionalAccessDocumentation.xlsx
# Graph metadata cache (now stored in the platform-specific data folder)
GraphMetaData.xml
CloudAPIPowerShellManagement/
# Local application settings (contains secrets on non-macOS platforms)
Settings.json
# Python
__pycache__/
*.pyc
*.pyo
.venv-pdf/
.venv/
# Local operational artifacts that are not part of the toolkit
accounts/
deploy.sh
restart_gateways.sh
+233
View File
@@ -0,0 +1,233 @@
# Agent Guide: macOS Intune Management Toolkit
> This file is written for AI coding agents. It assumes no prior knowledge of the project. Refer to `README.md`, `CHANGELOG_macOS_IntuneToolkit.md`, and `ReleaseNotes.md` for human-facing documentation.
## Project overview
This repository is **macOS Intune Management** (also referred to as the IntuneManagement toolkit). It is a cross-platform, headless, PowerShell-first CLI for exporting, importing, migrating, and managing Microsoft Intune policies across tenants.
- **Current version:** `4.1.0` (see `VERSION`).
- **Primary workflow:** export policies from a source tenant as JSON, optionally capture assignments, then import them into a target tenant with app-only, browser, or device-code authentication.
- **Additional capabilities:** declarative baseline deployment from YAML, bulk assignment management, backup/restore of assignments, bulk device operations, policy rename, CSV/Markdown reporting from local backups, and a CIS M365 rapid baseline for tenant-level workloads.
- **Important historical note:** the old WPF UI surface has been removed. The repo is intentionally CLI-first and headless.
There is no compiled application, no `pyproject.toml`/`package.json`/`Cargo.toml`/`Makefile`, and no CI/CD pipeline. The project is a collection of interpreted PowerShell modules/scripts with supporting Python utilities.
## Technology stack
- **PowerShell 7+ (`pwsh`)** — required runtime. Some launcher files declare `#requires -Version 5.1`, but headless scripts and the runtime target PowerShell 7 behaviors.
- **Microsoft Graph** — all tenant operations call the Graph `beta` (or optionally `v1.0`) endpoints.
- **MSAL.NET** — authentication is handled via `Microsoft.Identity.Client.dll` (shipped in `Bin/`). C# helpers in `CS/` are compiled at runtime with `Add-Type` when proxy or token-cache support is needed.
- **Python 3** — optional reporting utilities and CIS PDF conversion (`Scripts/*.py`). A local virtual environment `.venv-pdf` exists for PDF-specific dependencies (`pypdf`, `markdown-it`).
- **YAML** — baseline manifests are authored in YAML and consumed by `powershell-yaml`.
- **Optional `fzf`** — used by the TUI launchers for interactive menus; falls back to numbered prompts if missing.
### Required / optional dependencies
| Dependency | Where used | How to install |
|---|---|---|
| PowerShell 7+ | Everything | `brew install powershell` / `winget install Microsoft.PowerShell` |
| `Microsoft.Graph.Authentication` & `Microsoft.Graph.Applications` | `Initialize-IntuneAuth.ps1` | `Install-Module Microsoft.Graph -Scope CurrentUser -Force` |
| `powershell-yaml` | `Deploy-IntuneBaseline.ps1` | Script prompts to install; or `Install-Module powershell-yaml -Scope CurrentUser -Force` |
| `fzf` | TUI menus | `brew install fzf`, `apt install fzf`, `winget install junegunn.fzf` |
| Python 3 + `pypdf`/`markdown-it` | CIS PDF conversion (`_ConvertFrom-CISPDF.py`) | Recreate `.venv-pdf`: `python3 -m venv .venv-pdf && .venv-pdf/bin/pip install pypdf markdown-it` |
## Architecture and module organization
```text
.
├── Start-IntuneToolkit.ps1 # Unified terminal UI launcher (recommended entry point)
├── Core.psm1 # Headless runtime helpers, settings, logging, module loader
├── Runtime/
│ ├── IntuneManagement.Runtime.psd1 # Module manifest
│ └── IntuneManagement.Runtime.psm1 # Thin bootstrap: sets globals and starts Core app
├── Headless/
│ ├── IntuneManagement.Headless.psd1
│ └── IntuneManagement.Headless.psm1 # Adapter: default object types, Export/Import/Action wrappers
├── Extensions/ # Loaded automatically by Core.psm1
│ ├── MSGraph.psm1 # Graph API layer, object-type registry, import/export logic
│ ├── MSALAuthentication.psm1 # MSAL auth (app-only, browser, device code)
│ └── EndpointManager.psm1 # Intune view definitions and per-type lifecycle hooks
├── Scripts/ # User-facing entry scripts and private helpers
│ ├── Start-HeadlessIntune.ps1 # Single-action wrapper with optional TUI
│ ├── Export-Policies.ps1
│ ├── Import-Policies.ps1
│ ├── Initialize-IntuneAuth.ps1 # One-time Entra app + secret setup
│ ├── Deploy-IntuneBaseline.ps1 # YAML-driven baseline deployer
│ ├── ConvertTo-IntuneBaseline.ps1 # Export folder → baseline skeleton
│ ├── *_Bulk*.ps1 # Assignments, rename, device operations
│ ├── *.py # Reporting and PDF conversion utilities
│ └── Private/
│ └── Start-IntuneManagementTui.ps1
├── Baselines/ # Example and generated baseline manifests
│ ├── OpenIntuneBaseline.example.yaml
│ ├── CISM365-v7.example.yaml
│ ├── CISM365-v7-Generated.yaml
│ └── M365-CIS-Rapid/ # Config-driven tenant-level baseline
├── Bin/ # MSAL DLLs
├── CS/ # C# helper source compiled at runtime
└── .venv-pdf/ # Isolated Python venv for PDF processing
```
### How the pieces fit together
1. An entry script (`Start-IntuneToolkit.ps1`, `Export-Policies.ps1`, etc.) imports `Headless/IntuneManagement.Headless.psd1`.
2. The Headless module builds a JSON batch configuration describing the requested export/import action and writes it to a temporary file.
3. It imports `Runtime/IntuneManagement.Runtime.psd1` and calls `Initialize-IntuneManagementRuntime`, which sets global authentication variables and invokes `Start-CoreApp` from `Core.psm1`.
4. `Core.psm1` loads every `*.psm1` in `Extensions/` and dispatches module lifecycle hooks (`Invoke-InitializeModule`, `Invoke-SilentBatchJob`).
5. `Extensions/MSGraph.psm1` and `Extensions/EndpointManager.psm1` do the actual Graph calls and policy handling.
### Object-type registry
Intune object types are declared as `Add-ViewItem` entries in `Extensions/EndpointManager.psm1`. The headless default list (returned by `Get-DefaultIntunePolicyObjectTypes`) contains roughly 45 types, including:
- `DeviceConfiguration`, `SettingsCatalog`, `AdministrativeTemplates`
- `CompliancePolicies`, `CompliancePoliciesV2`, `EndpointSecurity`, `DeviceManagementIntents`
- `PolicySets`, `Applications`, `AppProtection`, `AppConfigurationManagedDevice`
- `ConditionalAccess`, `NamedLocations`, `TermsOfUse`
- Many more (scripts, branding, enrollment, update policies, etc.)
You can override the list per-run with the `-ObjectTypes` parameter.
## Configuration and data storage
The toolkit stores persistent configuration in a platform-specific JSON settings file:
- **macOS:** `~/Library/Application Support/macOS_IntuneManagement/Settings.json`
- **Windows:** `%LOCALAPPDATA%\macOS_IntuneManagement\Settings.json`
- **Linux:** `~/.local/share/macOS_IntuneManagement/Settings.json`
Per-tenant settings are stored under a key matching the tenant GUID. On macOS, the client secret is stored in the macOS Keychain (service `IntuneMgmt-<AppId>`, account `IntuneManagement`) instead of inside the JSON file. On non-macOS platforms the secret is written to the JSON file — treat that file as sensitive.
Operational logs are written to `IntuneManagement.log` in the same data folder by default.
## Authentication
Three authentication modes are supported, selected via `-AuthMode`:
- `AppOnly` (default) — uses an Entra app registration with client secret or certificate.
- `Browser` — interactive delegated auth using a public client. If `-AppId` is omitted, the Microsoft Graph PowerShell public client (`14d82eec-204b-4c2f-b7e8-296a70dab67e`) is used.
- `DeviceCode` — delegated auth via device code; may be blocked by Conditional Access.
First-time setup is performed by `Scripts/Initialize-IntuneAuth.ps1`, which:
1. Connects to Microsoft Graph with admin credentials.
2. Creates (or reuses) an Entra app registration named after the authenticated Entra user for audit traceability.
3. Ensures a broad set of Microsoft Graph application permissions are configured and grants admin consent.
4. Creates a client secret and stores it securely.
The launcher caches tenant display names in `Settings.json` so the TUI can show friendly names.
## Main entry points
| Script | Purpose |
|---|---|
| `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. Includes a `Platform` column and resolves Settings Catalog names from `configurationSettings.json` (auto-exported with Settings Catalog). |
| `Scripts/Export-EntraRoleMembership.ps1` | Export active + PIM-eligible Microsoft Entra directory role memberships (group assignments expanded) to CSV. |
| `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`. |
| `Scripts/Deploy-IntuneBaseline.ps1` | Declarative YAML baseline deployment with `-WhatIf` support. |
| `Scripts/ConvertTo-IntuneBaseline.ps1` | Convert an existing export into a baseline skeleton. |
| `Scripts/Bulk-AssignmentManager.ps1` | Bulk add/remove policy assignments. |
| `Scripts/Bulk-AppAssignment.ps1` | Bulk add/remove application assignments. |
| `Scripts/Backup-Restore-Assignments.ps1` | Backup and restore assignments across tenants. |
| `Scripts/Bulk-RenamePolicies.ps1` | Search/replace or prefix policy names and descriptions. |
| `Scripts/Bulk-DeviceOperations.ps1` | Delete, retire, wipe, lock, sync devices with `-WhatIf`. |
| `Scripts/Export-AssignmentsToCsv.ps1` | Export assignments to CSV/Markdown. |
## Baselines
- `Baselines/OpenIntuneBaseline.example.yaml` — example manifest for the declarative deployer.
- `Baselines/CISM365-v7.example.yaml` / `CISM365-v7-Generated.yaml` — CIS M365 v7 baseline manifests, generated from PDF.
- `Baselines/M365-CIS-Rapid/` — a separate config-driven baseline (`CISM365-RapidBaseline.psd1`) for tenant-level workloads (Entra ID, Conditional Access guidance, Exchange, SharePoint, Teams, Defender, Purview). See its `README.md` for prerequisites and workflow.
The YAML deployer supports groups, global/per-policy name mutations (`search`/`replace` or `prefix`), assignment targets, and conflict resolution (`Skip`, `Update`, `Error`).
## Build and test commands
There is no build step. Use the commands below to validate changes.
### PowerShell syntax validation
```powershell
# Validate one file
pwsh -Command "Get-Command ./Scripts/Export-Policies.ps1"
# Or parse tokens explicitly
pwsh -Command "
$err = $null
[System.Management.Automation.PSParser]::Tokenize(
(Get-Content -Raw ./Scripts/Deploy-IntuneBaseline.ps1), [ref]$err)
if ($err) { throw $err }
Write-Host 'Syntax OK'
"
```
### Module manifest validation
```powershell
pwsh -Command "Test-ModuleManifest ./Headless/IntuneManagement.Headless.psd1"
pwsh -Command "Test-ModuleManifest ./Runtime/IntuneManagement.Runtime.psd1"
```
### Python syntax validation
```bash
python3 -m py_compile Scripts/Export-SettingsReport.py
python3 -m py_compile Scripts/Export-AssignmentReport.py
python3 -m py_compile Scripts/Export-ObjectInventoryReport.py
python3 -m py_compile Scripts/_ConvertFrom-CISPDF.py
```
### What there is *not*
- No Pester test suite.
- No GitHub Actions, GitLab CI, or other continuous-integration configuration.
- No package-manager manifest (npm/pip/cargo/etc.).
## Testing instructions
1. **Syntax-check** modified PowerShell and Python files before committing.
2. **Use a non-production tenant** for functional testing.
3. **Prefer dry-run modes:**
- `Deploy-IntuneBaseline.ps1 -WhatIf`
- `Bulk-DeviceOperations.ps1 -WhatIf`
- `Start-HeadlessIntune.ps1 -Action GenerateReports` reads local backups only, making no Graph calls.
4. **Test auth setup end-to-end** with `Initialize-IntuneAuth.ps1`, then run `Export-Policies.ps1` without passing `-AppId`/`-Secret` to verify Keychain/settings resolution.
5. For the CIS rapid baseline, run `./Baselines/M365-CIS-Rapid/Deploy-CISM365RapidBaseline.ps1` in default assess mode first.
## Code style guidelines
- **PowerShell**
- Use `[CmdletBinding()]` on public functions/scripts.
- Name functions `Verb-Noun`; parameters are `PascalCase`.
- Entry scripts set `$ErrorActionPreference = "Stop"`.
- Use `[ValidateSet(...)]` for enum-like parameters.
- Use `Write-Host` with `-ForegroundColor` for user-facing menus; use `Write-Log` / `Write-LogError` for operational logging.
- JSON serialization uses `-Depth 20` or `-Depth 30` to avoid truncating nested objects.
- Do not introduce WPF/XAML dependencies — the UI layer has been removed.
- **YAML manifests**
- Follow the structure in `Baselines/OpenIntuneBaseline.example.yaml`.
- Use two-space indentation.
- **Python**
- Use `argparse`, type hints, and `pathlib.Path` in new scripts.
- Keep report scripts using only the standard library where possible.
## Security considerations
- **App-only auth creates a service principal with broad standing permissions.** Audit logs show the app's display name, not the individual admin's UPN. The initializer names the app after the authenticated Entra user to improve traceability.
- **PIM is not enforced for app-only secrets.** If strict Privileged Identity Management compliance is required, use delegated auth (`-AuthMode Browser` or `-AuthMode DeviceCode`) so actions run in the signed-in user's context.
- **Secret storage:** on macOS, secrets live in the Keychain. On other platforms, `GraphAzureAppSecret` is stored in `Settings.json` as plaintext. Never commit `Settings.json`, `IntuneManagement.log`, exported CSVs/ZIPs, or tenant backups.
- **Tenant lockout risk:** Conditional Access policies can lock you out. The toolkit and the CIS rapid baseline intentionally do not auto-create CA policies; create them manually in the Entra portal.
- **Destructive operations:** scripts support device wipe/retire/delete, app registration deletion, and bulk deletes. Always use `-WhatIf` first and confirm in a sandbox tenant.
- **Proxy support:** MSAL authentication can use a proxy URI configured via the `ProxyURI` setting. The C# helper `HttpFactoryWithProxy.cs` is compiled at runtime when needed.
## Deployment / release process
- The project is deployed manually: clone the repository and run `pwsh ./Start-IntuneToolkit.ps1` or the relevant script.
- Version is tracked in the `VERSION` file and referenced by `README.md` and `CHANGELOG_macOS_IntuneToolkit.md`.
- There is no installer, no signed module package, and no automated release pipeline.
- When adding a new workload or object type, register it in both `Extensions/EndpointManager.psm1` (as an `Add-ViewItem`) and `Headless/IntuneManagement.Headless.psm1` (in `Get-DefaultIntunePolicyObjectTypes`) if it should be available headlessly.
+655
View File
@@ -0,0 +1,655 @@
# =====================================================================
# CIS Microsoft 365 Foundations Benchmark v7.0.0 (Draft)
# GENERATED from PDF — review before deploying
# =====================================================================
baseline:
name: CIS-M365-v7-Generated
conflictResolution: Skip
whatIf: false
tenantMutation:
prefix: "CIS-v7-"
groups:
- displayName: "CIS-BreakGlass"
mailNickname: "CISBreakGlass"
securityEnabled: true
- displayName: "CIS-Pilot-Users"
mailNickname: "CISPilotUsers"
securityEnabled: true
tenantConfig:
# ===============================================================
# Section 1: adminCenter
# ===============================================================
adminCenter:
# 1.1.2 (Manual): Ensure two emergency access accounts have been defined
# TODO: Implement manually per PDF instructions
# 1.1.3 (Automated): Ensure that between two and four global admins are designated
# TODO: Map this control to YAML — see PDF for details
# 1.1.4 (Automated): Ensure administrative accounts use licenses with a reduced application footprint
# TODO: Map this control to YAML — see PDF for details
# 1.2.1 (Automated): Ensure that only organizationally managed/approved public groups exist
# TODO: Map this control to YAML — see PDF for details
# 1.2.2: Ensure sign-in to shared mailboxes is blocked
blockSharedMailboxSignIn: true
# 1.3.1: Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)'
passwordExpiration: "NeverExpire"
# 1.3.2: Ensure 'Idle session timeout' is set to '3 hours (or less)' for unmanaged devices
idleSessionTimeoutHours: 3
# 1.3.3: Ensure 'External sharing' of calendars is not available
externalCalendarSharing: "Disabled"
# 1.3.4: Ensure 'User owned apps and services' is restricted
restrictUserOwnedApps: true
# 1.3.5: Ensure internal phishing protection for Forms is enabled
formsPhishingProtection: true
# 1.3.6: Ensure the customer lockbox feature is enabled
customerLockbox: true
# 1.3.7: Ensure 'third-party storage services' are restricted in 'Microsoft 365 on the web'
restrictThirdPartyStorage: true
# 1.3.8 (Manual): Ensure that Sways cannot be shared with people outside of your organization
# TODO: Implement manually per PDF instructions
# 1.3.9: Ensure shared bookings pages are restricted to select users
restrictSharedBookings: true
# ===============================================================
# Section 5: entraId
# ===============================================================
entraId:
# 5.1.2.1 (Manual): Ensure 'Per-user MFA' is disabled
# TODO: Implement manually per PDF instructions
# 5.1.2.2: Ensure users cannot register applications
blockUserConsent: true
# 5.1.2.3: Ensure 'Restrict non-admin users from creating tenants' is set to 'Yes'
blockTenantCreation: true
# 5.1.2.4 (Manual): Ensure access to the Entra admin center is restricted
# TODO: Implement manually per PDF instructions
# 5.1.2.5 (Manual): Ensure the option to remain signed in is hidden
# TODO: Implement manually per PDF instructions
# 5.1.2.6 (Manual): Ensure 'LinkedIn account connections' is disabled
# TODO: Implement manually per PDF instructions
# 5.1.3.1: Ensure users cannot create security groups
blockSecurityGroupCreation: true
# 5.1.3.2 (Manual): Ensure that 'Restrict user ability to access groups features in My Groups' is set to 'Yes'
# TODO: Implement manually per PDF instructions
# 5.1.3.3 (Manual): Ensure that 'Owners can manage group membership requests in My Groups' is set to 'No'
# TODO: Implement manually per PDF instructions
# 5.1.3.4: Ensure that 'Users can create Microsoft 365 groups in Azure portals, API or PowerShell' is set to 'No'
blockM365GroupCreation: true
# 5.1.4.1: Ensure the ability to join devices to Entra is restricted
restrictDeviceJoin: true
# 5.1.4.2: Ensure the maximum number of devices per user is limited
maxDevicesPerUser: 5
# 5.1.4.3: Ensure the GA role is not added as a local administrator during Entra join
gaLocalAdminDisabled: true
# 5.1.4.4: Ensure local administrator assignment is limited during Entra join
limitLocalAdminAssignment: true
# 5.1.4.5: Ensure Local Administrator Password Solution is enabled
enableLAPS: true
# 5.1.4.6: Ensure users are restricted from recovering BitLocker keys
restrictBitLockerRecovery: true
# 5.1.5.1: Ensure user consent to apps accessing company data on their behalf is not allowed
blockUserConsent: true
# 5.1.5.2: Ensure the admin consent workflow is enabled
enableAdminConsentWorkflow: true
# 5.1.5.3: Ensure password addition is blocked for applications
blockPasswordCredentials: true
# 5.1.5.4: Ensure password lifetime for applications does not exceed 180 days
maxPasswordLifetimeDays: 180
# 5.1.5.5: Ensure new application passwords are system-generated
systemGeneratedPasswords: true
# 5.1.5.6: Ensure maximum certificate lifetime for applications does not exceed 180 days
maxCertificateLifetimeDays: 180
# 5.1.6.1: Ensure that collaboration invitations are sent to allowed domains only
restrictCollaborationDomains: true
# 5.1.6.2: Ensure that guest user access is restricted
restrictGuestAccess: true
# 5.1.6.3: Ensure guest user invitations are limited
limitGuestInvitations: true
# 5.1.8.1: Ensure that password hash sync is enabled for hybrid deployments
enablePasswordHashSync: true
# 5.2.3.1: Ensure Microsoft Authenticator is configured to protect against MFA fatigue
authenticatorNumberMatching: true
# 5.2.3.3 (Automated): Ensure password protection is enabled for on-prem Active Directory
# NOTE: Hybrid-only control — requires on-premises Active Directory
# 5.2.3.4: Ensure all member users are 'MFA capable'
mfaCapableUsers: true
# 5.2.3.5: Ensure weak authentication methods are disabled
disableWeakAuthMethods: true
# 5.2.3.6: Ensure system-preferred multifactor authentication is enabled
systemPreferredMFA: true
# 5.2.3.7: Ensure the email OTP authentication method is disabled
disableEmailOTP: true
# 5.2.3.8: Ensure that Account 'Lockout threshold' is '10' or less
lockoutThreshold: 10
# 5.2.3.9: Ensure that Account 'Lockout duration in seconds' is at least 60 seconds
lockoutDurationSeconds: 60
# 5.2.3.10: Ensure Microsoft Authenticator on companion applications is disabled
disableAuthenticatorCompanionApps: true
# 5.2.4.1 (Manual): Ensure 'Self service password reset enabled' is set to 'All'
# TODO: Implement manually per PDF instructions
# 5.2.4.2 (Manual): Ensure that 2 methods are required for password reset
# TODO: Implement manually per PDF instructions
# 5.2.4.3 (Manual): Ensure SSPR registration and authentication re- confirmation are required
# TODO: Implement manually per PDF instructions
# 5.2.4.4 (Manual): Ensure that users are notified on password resets
# TODO: Implement manually per PDF instructions
# 5.2.4.5 (Manual): Ensure all admins are notified when other admins reset their password
# TODO: Implement manually per PDF instructions
# 5.3.1: Ensure privileged role assignments are activated and not assigned
pimRoleActivationRequired: true
# 5.3.2: Ensure 'Access reviews' for guest users are configured
accessReviewsForGuests: true
# 5.3.3: Ensure 'Access reviews' for privileged roles are configured
accessReviewsForPrivilegedRoles: true
# 5.3.4: Ensure approval is required for Global Administrator role activation
requireApprovalForGAActivation: true
# 5.3.5: Ensure approval is required for Privileged Role Administrator activation
requireApprovalForPRAActivation: true
# ===============================================================
# Section 6: exchange
# ===============================================================
exchange:
# 6.1.1: Ensure 'AuditDisabled' organizationally is set to 'False'
enableMailboxAuditOrgWide: true
# 6.1.2: Ensure mailbox audit actions are configured
configureMailboxAuditActions: true
# 6.1.3: Ensure 'AuditBypassEnabled' is not enabled on mailboxes
disableAuditBypass: true
# 6.2.1: Ensure all forms of mail forwarding are blocked and/or disabled
blockExternalForwarding: true
# 6.2.2: Ensure mail transport rules do not whitelist specific domains
noDomainWhitelistTransportRules: true
# 6.2.3: Ensure email from external senders is identified
enableExternalSenderBanner: true
# 6.3.1: Ensure users installing Outlook add-ins is not allowed
blockOutlookAddIns: true
# 6.3.2: Ensure the ability to add personal email accounts and calendars is disabled
disablePersonalEmailAccounts: true
# 6.5.1: Ensure modern authentication for Exchange Online is enabled
enableModernAuthExchange: true
# 6.5.2: Ensure MailTips are enabled for end users
enableMailTips: true
# 6.5.3: Ensure additional storage providers are restricted in Outlook on the web
restrictAdditionalStorageProviders: true
# 6.5.4: Ensure SMTP AUTH is disabled
disableSMTPAuth: true
# 6.5.5: Ensure Direct Send submissions are rejected
rejectDirectSend: true
# ===============================================================
# Section 7: sharePoint
# ===============================================================
sharePoint:
# 7.2.1: Ensure modern authentication for SharePoint applications is required
requireModernAuthSharePoint: true
# 7.2.2: Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled
enableAADB2BIntegration: true
# 7.2.3: Ensure external content sharing is restricted
sharePointExternalSharing: "Disabled"
# 7.2.4: Ensure OneDrive content sharing is restricted
oneDriveExternalSharing: "Disabled"
# 7.2.5: Ensure that SharePoint guest users cannot share items they don't own
preventGuestResharing: true
# 7.2.6: Ensure SharePoint external sharing is restricted
restrictSharePointExternalSharing: true
# 7.2.7: Ensure link sharing is restricted in SharePoint and OneDrive
restrictLinkSharing: true
# 7.2.8: Ensure external sharing is restricted by security group
restrictSharingBySecurityGroup: true
# 7.2.9: Ensure guest access to a site or OneDrive will expire automatically
guestAccessExpirationDays: 30
# 7.2.10: Ensure reauthentication with verification code is restricted
restrictReauthenticationVerificationCode: true
# 7.2.11: Ensure the SharePoint default sharing link permission is set
defaultSharingLinkPermission: "View"
# 7.3.1: Ensure Office 365 SharePoint infected files are disallowed for download
disallowInfectedFileDownload: true
# ===============================================================
# Section 8: teams
# ===============================================================
teams:
# 8.1.1: Ensure external file sharing in Teams is enabled for only approved cloud storage services
restrictExternalFileSharing: true
# 8.1.2: Ensure users can't send emails to a channel email address
blockChannelEmail: true
# 8.2.1: Ensure external domains are restricted in the Teams admin center
restrictExternalDomains: true
# 8.2.2: Ensure communication with unmanaged Teams users is disabled
disableUnmanagedUserCommunication: true
# 8.2.3: Ensure external Teams users cannot initiate conversations
blockExternalUserInitiation: true
# 8.2.4: Ensure the organization cannot communicate with accounts in trial Teams tenants
blockTrialTenantCommunication: true
# 8.4.1 (Manual): Ensure app permission policies are configured
# TODO: Implement manually per PDF instructions
# 8.5.1: Ensure anonymous users can't join a meeting
allowAnonymousUsersToJoinMeeting: false
# 8.5.2: Ensure anonymous users and dial-in callers can't start a meeting
allowAnonymousUsersToStartMeeting: false
# 8.5.3: Ensure only people in my org can bypass the lobby
orgOnlyBypassLobby: true
# 8.5.4: Ensure users dialing in can't bypass the lobby
dialInCantBypassLobby: true
# 8.5.5: Ensure meeting chat does not allow anonymous users
noAnonymousMeetingChat: true
# 8.5.6: Ensure only organizers and co-organizers can present
onlyOrganizersCanPresent: true
# 8.5.7: Ensure external participants can't give or request control
noExternalControl: true
# 8.5.8: Ensure external meeting chat is off
externalMeetingChatOff: true
# 8.5.9: Ensure meeting recording is off by default
meetingRecordingOffByDefault: true
# 8.6.1: Ensure users can report security concerns in Teams
enableSecurityConcernsReporting: true
# ===============================================================
# Section 9: powerBI
# ===============================================================
powerBI:
# 9.1.1: Ensure guest user access is restricted
restrictGuestAccess: true
# 9.1.2: Ensure external user invitations are restricted
restrictExternalInvitations: true
# 9.1.3: Ensure guest access to content is restricted
restrictGuestContentAccess: true
# 9.1.4: Ensure 'Publish to web' is restricted
restrictPublishToWeb: true
# 9.1.5: Ensure 'Interact with and share R and Python' visuals is 'Disabled'
disableRPythonVisuals: true
# 9.1.6: Ensure 'Allow users to apply sensitivity labels for content' is 'Enabled'
enableSensitivityLabels: true
# 9.1.7: Ensure shareable links are restricted
restrictShareableLinks: true
# 9.1.8: Ensure enabling of external data sharing is restricted
restrictExternalDataSharing: true
# 9.1.9: Ensure 'Block ResourceKey Authentication' is 'Enabled'
blockResourceKeyAuth: true
# 9.1.10: Ensure access to APIs by service principals is restricted
restrictServicePrincipalAPIAccess: true
# 9.1.11: Ensure service principals cannot create and use profiles
blockServicePrincipalProfiles: true
# 9.1.12: Ensure service principals ability to create workspaces, connections and deployment pipelines is restricted
restrictServicePrincipalWorkspaceCreation: true
# ===============================================================
# Section 3: purview
# ===============================================================
purview:
# 3.1.1: Ensure Microsoft 365 audit log search is Enabled
enableAuditLogSearch: true
# 3.2.1 (Automated): Ensure DLP policies are enabled
# TODO: Map this control to YAML — see PDF for details
# 3.2.2 (Automated): Ensure DLP policies are enabled for Microsoft Teams
# TODO: Map this control to YAML — see PDF for details
# 3.2.3 (Automated): Ensure DLP policies are published for Copilot users
# TODO: Map this control to YAML — see PDF for details
# 3.3.1 (Automated): Ensure Information Protection sensitivity label policies are published
# TODO: Map this control to YAML — see PDF for details
# ===============================================================
# Section 2: Defender for Office 365
# ===============================================================
defender:
# 2.1.1: Ensure Safe Links for Office Applications is Enabled
safeLinks:
name: "SafeLinks-Default"
enabled: true
trackClicks: true
allowClickThrough: false
scanUrls: true
enableForInternalSenders: true
# 2.1.2: Ensure the Common Attachment Types Filter is enabled
antiMalware:
name: "AntiMalware-Default"
enabled: true
enableInternalNotifications: true
fileTypes: ["ace", "ani", "app", "docm", "exe", "jar", "jnlp", "msi", "ps1", "scr", "vbs", "wsf"]
# 2.1.3: Ensure notifications for internal users sending malware is Enabled
antiMalware:
name: "AntiMalware-InternalNotify"
enabled: true
enableInternalNotifications: true
# 2.1.4: Ensure Safe Attachments policy is enabled
safeAttachments:
name: "SafeAttachments-Default"
enabled: true
action: "Block"
quarantineMessages: true
# 2.1.5: Ensure Safe Attachments for SharePoint, OneDrive, and Microsoft Teams is Enabled
safeAttachments:
name: "SafeAttachments-SPO-Teams"
enabled: true
action: "Block"
enableForSharePoint: true
enableForTeams: true
# 2.1.6: Ensure Exchange Online Spam Policies are set to notify administrators
antiSpam:
name: "AntiSpam-Notify-Admins"
enabled: true
notifyAdmins: true
# 2.1.7: Ensure that an anti-phishing policy has been created
antiPhish:
name: "AntiPhish-Default"
enabled: true
enableMailboxIntelligence: true
enableSpoofIntelligence: true
mailboxIntelligenceProtectionAction: "Quarantine"
# 2.1.8 (Automated): Ensure that SPF records are published for all Exchange Domains
# NOTE: DNS-level control — configure via DNS provider, not M365 tenant
# 2.1.9 (Automated): Ensure that DKIM is enabled for all Exchange Online Domains
# NOTE: DNS-level control — configure via DNS provider, not M365 tenant
# 2.1.10 (Automated): Ensure DMARC records for all Exchange Online domains are published
# NOTE: DNS-level control — configure via DNS provider, not M365 tenant
# 2.1.11: Ensure comprehensive attachment filtering is applied
antiMalware:
name: "AntiMalware-Comprehensive"
enabled: true
enableFileFilter: true
# 2.1.12: Ensure the connection filter IP allow list is not used
connectionFilterIPAllowListEmpty: true
# 2.1.13: Ensure the connection filter safe list is off
connectionFilterSafeListOff: true
# 2.1.14: Ensure inbound anti-spam policies do not contain allowed domains
inboundAntiSpamNoAllowedDomains: true
# 2.1.15: Ensure outbound anti-spam message limits are in place
outboundAntiSpamLimits: true
# 2.2.1 (Manual): Ensure emergency access account activity is monitored
# 2.4.1: Ensure Priority account protection is enabled and configured
priorityAccount:
enabled: true
# 2.4.2: Ensure Priority accounts have 'Strict protection' presets applied
priorityAccount:
strictProtection: true
# 2.4.3 (Manual): Ensure Microsoft Defender for Cloud Apps is enabled and configured
# 2.4.4: Ensure Zero-hour auto purge for Microsoft Teams is on
zap:
enabledForTeams: true
# 2.4.5 (Manual): Ensure 'AIR' remediation is enabled
# ===============================================================
# Section 5.2.2: Conditional Access
# ===============================================================
conditionalAccess:
reportOnly: true
breakGlassGroup: "CIS-BreakGlass"
policies:
- name: "Ensure-multifactor-authentication-is-enabled-for-all-us"
cisControl: "5.2.2.1"
description: "Ensure multifactor authentication is enabled for all users in administrative roles"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeRoles:
- "Global Administrator"
- "Privileged Role Administrator"
- "Security Administrator"
- "Exchange Administrator"
- "SharePoint Administrator"
- "Conditional Access Administrator"
- "Application Administrator"
- "Cloud Application Administrator"
- "User Administrator"
- "Helpdesk Administrator"
- "Billing Administrator"
- "Authentication Administrator"
- "Password Administrator"
- "Global Reader"
grantControls:
builtInControls: ["mfa"]
operator: "OR"
- name: "Ensure-multifactor-authentication-is-enabled-for-all-us"
cisControl: "5.2.2.2"
description: "Ensure multifactor authentication is enabled for all users"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["mfa"]
operator: "OR"
- name: "Enable-Conditional-Access-policies-to-block-legacy-auth"
cisControl: "5.2.2.3"
description: "Enable Conditional Access policies to block legacy authentication"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
clientAppTypes: ["exchangeActiveSync", "other"]
grantControls:
builtInControls: ["block"]
operator: "OR"
- name: "Ensure-Signin-frequency-is-enabled-and-browser-sessions"
cisControl: "5.2.2.4"
description: "Ensure Sign-in frequency is enabled and browser sessions are not persistent for Administrative users"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeRoles:
- "Global Administrator"
- "Privileged Role Administrator"
- "Security Administrator"
- "Exchange Administrator"
- "SharePoint Administrator"
- "Conditional Access Administrator"
- "Application Administrator"
- "Cloud Application Administrator"
- "User Administrator"
- "Helpdesk Administrator"
- "Billing Administrator"
- "Authentication Administrator"
- "Password Administrator"
- "Global Reader"
grantControls:
builtInControls: ["mfa"]
operator: "OR"
sessionControls:
signInFrequency:
value: 12
type: hours
isEnabled: true
persistentBrowser:
mode: never
isEnabled: true
- name: "Ensure-Phishingresistant-MFA-strength-is-required-for-A"
cisControl: "5.2.2.5"
description: "Ensure 'Phishing-resistant MFA strength' is required for Administrators"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeRoles:
- "Global Administrator"
- "Privileged Role Administrator"
- "Security Administrator"
- "Exchange Administrator"
- "SharePoint Administrator"
- "Conditional Access Administrator"
- "Application Administrator"
- "Cloud Application Administrator"
- "User Administrator"
- "Helpdesk Administrator"
- "Billing Administrator"
- "Authentication Administrator"
- "Password Administrator"
- "Global Reader"
grantControls:
builtInControls: ["authenticationStrength"]
authenticationStrength:
id: "00000000-0000-0000-0000-000000000004"
operator: "OR"
- name: "Enable-Identity-Protection-user-risk-policies"
cisControl: "5.2.2.6"
description: "Enable Identity Protection user risk policies"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
signInRiskLevels: ["medium", "high"]
grantControls:
builtInControls: ["mfa"]
operator: "OR"
- name: "Enable-Identity-Protection-signin-risk-policies"
cisControl: "5.2.2.7"
description: "Enable Identity Protection sign-in risk policies"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
signInRiskLevels: ["medium", "high"]
grantControls:
builtInControls: ["mfa"]
operator: "OR"
- name: "Ensure-signin-risk-is-blocked-for-medium-and-high-risk"
cisControl: "5.2.2.8"
description: "Ensure 'sign-in risk' is blocked for medium and high risk"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
signInRiskLevels: ["medium", "high"]
grantControls:
builtInControls: ["block"]
operator: "OR"
- name: "Ensure-a-managed-device-is-required-for-authentication"
cisControl: "5.2.2.9"
description: "Ensure a managed device is required for authentication"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["compliantDevice", "domainJoinedDevice"]
operator: "OR"
- name: "Ensure-a-managed-device-is-required-to-register-securit"
cisControl: "5.2.2.10"
description: "Ensure a managed device is required to register security information"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeUserActions: ["urn:user:registersecurityinfo"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["compliantDevice", "domainJoinedDevice"]
operator: "OR"
- name: "Ensure-signin-frequency-for-Intune-Enrollment-is-set-to"
cisControl: "5.2.2.11"
description: "Ensure sign-in frequency for Intune Enrollment is set to 'Every time'"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["0000000a-0000-0000-c000-000000000000"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["mfa"]
operator: "OR"
sessionControls:
signInFrequency:
value: 12
type: hours
isEnabled: true
persistentBrowser:
mode: never
isEnabled: true
- name: "Ensure-the-device-code-signin-flow-is-blocked"
cisControl: "5.2.2.12"
description: "Ensure the device code sign-in flow is blocked"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
authenticationFlows:
deviceCodeFlow:
isEnabled: true
grantControls:
builtInControls: ["block"]
operator: "OR"
- name: "Ensure-that-periodic-reauthentication-is-required-for-a"
cisControl: "5.2.2.13"
description: "Ensure that periodic reauthentication is required for all users"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["mfa"]
operator: "OR"
- name: "Ensure-trusted-named-locations-are-defined"
cisControl: "5.2.2.14"
description: "Ensure trusted 'named locations' are defined"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
# TODO: Define named locations in Entra admin center
grantControls:
builtInControls: ["mfa"]
operator: "OR"
- name: "Ensure-exclusionary-geographic-access-controls-are-util"
cisControl: "5.2.2.15"
description: "Ensure exclusionary geographic access controls are utilized"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
# TODO: Define named locations in Entra admin center
grantControls:
builtInControls: ["mfa"]
operator: "OR"
- name: "Ensure-Token-Protection-is-enforced-for-session-tokens"
cisControl: "5.2.2.16"
description: "Ensure Token Protection is enforced for session tokens"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["mfa"]
operator: "OR"
# TODO: Enable Token Protection via Authentication Strength policy
- name: "Ensure-authentication-transfer-is-blocked"
cisControl: "5.2.2.17"
description: "Ensure authentication transfer is blocked"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["block"]
operator: "OR"
+466
View File
@@ -0,0 +1,466 @@
# =====================================================================
# CIS Microsoft 365 Foundations Benchmark v7.0.0 (Draft)
# Tenant-Level Baseline Manifest
# =====================================================================
# This YAML extends the OpenIntuneBaseline format to cover M365 tenant
# configuration: Entra ID, Conditional Access, Defender, Exchange,
# SharePoint, and Teams.
#
# HOW TO USE WITH A DRAFT PDF:
# 1. Copy this file to your own baseline (e.g., mytenant-cisv7.yaml)
# 2. As you read the CIS v7.0.0 PDF, transcribe controls into the
# appropriate sections below. Each control has a 'cisControl' field
# for traceability.
# 3. Customize names, exclusions, and groups for your tenant.
# 4. Run: ./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath ./Baselines/mytenant-cisv7.yaml
#
# SAFETY:
# - Conditional Access policies default to 'reportOnly: true' (globally)
# and 'state: enabledForReportingButNotEnforced' (per-policy).
# - The script also supports -WhatIf.
# - Break-glass accounts/groups are automatically excluded from CA.
# =====================================================================
baseline:
name: CIS-M365-v7-Example
conflictResolution: Skip # Skip | Update | Error
whatIf: false
# -------------------------------------------------------------------
# Global name mutation applied to every policy / CA rule (optional)
# -------------------------------------------------------------------
tenantMutation:
search: "CIS-v7-"
replace: "ACME-CIS-"
# Alternatively use prefix instead of search/replace:
# prefix: "ACME-CIS-"
# -------------------------------------------------------------------
# Cloud-only security groups (mirrors Intune baseline format)
# These are created if they do not exist and can be referenced
# in CA policy assignments by displayName.
# -------------------------------------------------------------------
groups:
- displayName: "CIS-BreakGlass"
mailNickname: "CISBreakGlass"
securityEnabled: true
- displayName: "CIS-Pilot-Users"
mailNickname: "CISPilotUsers"
securityEnabled: true
- displayName: "CIS-All-Company"
mailNickname: "CISAllCompany"
securityEnabled: true
# -------------------------------------------------------------------
# Intune policies (optional — reuses the exact same schema as
# OpenIntuneBaseline.example.yaml). Keep them here if you want a
# single manifest for the whole tenant.
# -------------------------------------------------------------------
policies:
# Example: reuse your existing Intune exports
# - sourcePath: ./policies/CIS-Windows-Compliance.json
# type: CompliancePolicies
# assignments:
# - targetType: Group
# groupName: "CIS-All-Company"
# -------------------------------------------------------------------
# TENANT-LEVEL CONFIGURATION (new section)
# -------------------------------------------------------------------
tenantConfig:
# ===============================================================
# 1. M365 Admin Center (CIS Section 1)
# ===============================================================
adminCenter:
# 1.3.1 (L1) Password expiration
passwordExpiration: NeverExpire # NeverExpire | 90Days | 180Days
# 1.3.2 (L2) Idle session timeout (hours)
idleSessionTimeoutHours: 3
# 1.3.4 (L1) Restrict user owned apps and services
restrictUserOwnedApps: true
# 1.3.5 (L1) Internal phishing protection for Forms
formsPhishingProtection: true
# 1.3.6 (L2) Customer Lockbox
customerLockbox: true
# 1.3.7 (L2) Restrict third-party storage services
restrictThirdPartyStorage: true
# ===============================================================
# 5. Entra ID (CIS Section 5)
# ===============================================================
entraId:
# 5.1.1.1 (L1) Cloud-only administrative accounts
# NOTE: Manual — script can only validate, not create accounts.
# 5.1.1.3 (L1) Global admin count (2-4)
# NOTE: Manual — script assesses only.
# 5.1.2.2 (L2) Disallow third-party integrated applications
blockUserConsent: true
# 5.1.2.3 (L1) Restrict non-admin tenant creation
blockTenantCreation: true
# 5.1.2.4 (L1) Restrict access to Entra admin center
restrictAdminCenterAccess: true
# 5.1.2.6 (L2) Disable LinkedIn account connections
disableLinkedIn: true
# 5.1.3.1 (L1) Dynamic group for guest users
# NOTE: Manual — requires tenant-specific query.
# 5.1.4.2 (L1) Maximum devices per user
maxDevicesPerUser: 5
# 5.1.4.3 (L1) GA not added as local admin during Entra join
gaLocalAdminDisabled: true
# 5.2.3.2 (L1) Custom banned password list
bannedPasswords:
- "Contoso"
- "Password"
- "Welcome"
- "Admin"
- "Login"
# 5.2.3.4 (L1) Ensure all member users are MFA capable
# NOTE: Enforced via Conditional Access below.
# ===============================================================
# 5.2.2 Conditional Access (CIS Section 5.2.2)
# ===============================================================
# CRITICAL: All CA policies are created in REPORT-ONLY mode by
# default. Flip 'reportOnly: false' after you have validated
# traffic in the Entra admin center.
# ===============================================================
conditionalAccess:
reportOnly: true # Global switch for all CA policies
breakGlassGroup: "CIS-BreakGlass" # Auto-excluded from every CA policy
policies:
# -----------------------------------------------------------
# CIS 5.2.2.3 (L1) Block legacy authentication
# -----------------------------------------------------------
- name: "Block-Legacy-Auth"
cisControl: "5.2.2.3"
description: "Block all legacy authentication protocols (EAS, basic auth)"
state: enabledForReportingButNotEnforced # enabled | enabledForReportingButNotEnforced | disabled
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
# breakGlassGroup is injected automatically by the script
clientAppTypes: ["exchangeActiveSync", "other"]
grantControls:
builtInControls: ["block"]
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.1 (L1) Require MFA for administrative roles
# -----------------------------------------------------------
- name: "Require-MFA-Admins"
cisControl: "5.2.2.1"
description: "Require MFA for all users assigned to administrative roles"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeRoles:
- "Global Administrator"
- "Privileged Role Administrator"
- "Security Administrator"
- "Exchange Administrator"
- "SharePoint Administrator"
- "Conditional Access Administrator"
- "Application Administrator"
- "Cloud Application Administrator"
- "User Administrator"
- "Helpdesk Administrator"
- "Billing Administrator"
- "Authentication Administrator"
- "Password Administrator"
- "Global Reader"
excludeUsers: [] # Add break-glass UPNs here if not using breakGlassGroup
grantControls:
builtInControls: ["mfa"]
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.2 (L1) Require MFA for all users
# -----------------------------------------------------------
- name: "Require-MFA-All-Users"
cisControl: "5.2.2.2"
description: "Require MFA for all user sign-ins"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
excludeGroups: [] # e.g., ["CIS-Pilot-Users"] for staged rollout
locations:
includeLocations: ["AllTrusted"] # Requires named locations; use "All" if none defined
grantControls:
builtInControls: ["mfa"]
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.4 (L1) Sign-in frequency for admins
# -----------------------------------------------------------
- name: "Admin-SignIn-Frequency"
cisControl: "5.2.2.4"
description: "Require re-authentication every 12h for admins; no persistent browser"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeRoles:
- "Global Administrator"
- "Privileged Role Administrator"
- "Security Administrator"
sessionControls:
signInFrequency:
value: 12
type: hours
isEnabled: true
persistentBrowser:
mode: never
isEnabled: true
grantControls:
builtInControls: ["mfa"]
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.5 (L2) Phishing-resistant MFA for admins
# -----------------------------------------------------------
- name: "Require-PhishingResistant-MFA-Admins"
cisControl: "5.2.2.5"
description: "Require phishing-resistant MFA (FIDO2, certificate) for admins"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeRoles:
- "Global Administrator"
- "Privileged Role Administrator"
- "Security Administrator"
grantControls:
builtInControls: ["authenticationStrength"]
authenticationStrength:
id: "00000000-0000-0000-0000-000000000004" # Phishing-resistant MFA
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.12 (L1) Block device code flow
# -----------------------------------------------------------
- name: "Block-Device-Code-Flow"
cisControl: "5.2.2.12"
description: "Block sign-ins using the device code authentication flow"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
authenticationFlows:
deviceCodeFlow:
isEnabled: true
ruleType: "include"
grantControls:
builtInControls: ["block"]
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.8 (L2) Block medium/high risk sign-ins
# -----------------------------------------------------------
- name: "Block-HighRisk-SignIns"
cisControl: "5.2.2.8"
description: "Block sign-ins with medium or high risk score (requires Entra ID P2)"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
signInRiskLevels: ["medium", "high"]
grantControls:
builtInControls: ["block"]
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.9 (L1) Require managed device
# -----------------------------------------------------------
- name: "Require-Managed-Device"
cisControl: "5.2.2.9"
description: "Require device to be compliant or hybrid Entra joined"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["compliantDevice", "domainJoinedDevice"]
operator: "OR"
# -----------------------------------------------------------
# CIS 5.2.2.10 (L1) Require managed device to register security info
# -----------------------------------------------------------
- name: "Require-Managed-Device-Security-Info"
cisControl: "5.2.2.10"
description: "Require managed device when registering security information"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeUserActions: ["urn:user:registersecurityinfo"]
users:
includeUsers: ["All"]
grantControls:
builtInControls: ["compliantDevice", "domainJoinedDevice"]
operator: "OR"
# ===============================================================
# 2. Microsoft Defender for Office 365 (CIS Section 2)
# ===============================================================
defender:
# 2.1.1 (L2) Safe Links for Office Applications
safeLinks:
- name: "SafeLinks-Default"
cisControl: "2.1.1"
enabled: true
trackClicks: true
allowClickThrough: false
scanUrls: true
enableForInternalSenders: true
# The script auto-creates a rule applying this to all accepted domains
# 2.1.4 (L2) Safe Attachments
safeAttachments:
- name: "SafeAttachments-Default"
cisControl: "2.1.4"
enabled: true
action: Block # Block | DynamicDelivery | Monitor
quarantineMessages: true
# 2.1.2 (L1) Common Attachment Types Filter
antiMalware:
- name: "AntiMalware-Default"
cisControl: "2.1.2"
enabled: true
enableInternalNotifications: true
fileTypes:
- ace
- ani
- app
- docm
- exe
- jar
- jnlp
- msi
- ps1
- scr
- vbs
- wsf
# 2.1.3 (L1) Internal malware notifications
# 2.4.4 (L1) Zero-hour auto purge for Teams
# ===============================================================
# 6. Exchange Online (CIS Section 6)
# ===============================================================
exchange:
# 6.1.1 (L1) AuditDisabled organizationally set to False
enableMailboxAuditOrgWide: true
# 6.1.2 (L1) Mailbox audit actions configured
# NOTE: Enabled automatically when org-wide auditing is on (above).
# 6.2.1 (L1) Block all forms of external forwarding
blockExternalForwarding: true
# 6.2.2 (L1) Transport rules do not whitelist domains
# NOTE: Manual review required.
# 6.2.3 (L1) Identify email from external senders
enableExternalSenderBanner: true
# Transport rule: prepend external email warning
externalEmailWarningRule: true
# ===============================================================
# 7. SharePoint / OneDrive (CIS Section 7)
# ===============================================================
sharePoint:
# Default sharing link type
defaultSharingLinkType: Direct # Direct | Internal | AnonymousAccess
# External sharing for SharePoint
sharePointExternalSharing: Disabled
# Options: Disabled | ExistingExternalUserSharingOnly | ExternalUserSharingOnly | Anyone
# External sharing for OneDrive
oneDriveExternalSharing: Disabled
# Guest access expiration (days)
guestAccessExpirationDays: 30
# 7.x (L1) Prevent custom script execution
# NOTE: Set via Set-PnPTenant -DenyAddAndCustomizePages 1
denyCustomScripts: true
# ===============================================================
# 8. Microsoft Teams (CIS Section 8)
# ===============================================================
teams:
# 8.x Anonymous meeting join
allowAnonymousUsersToJoinMeeting: false
# 8.x Anonymous meeting start
allowAnonymousUsersToStartMeeting: false
# 8.x Teams email integration
enableEmailIntegration: false
# 8.x Federation / external access
allowFederatedUsers: false
allowTeamsConsumer: false
# 8.x Restrict unmanaged user access
# NOTE: Controlled via Teams meeting policy; script sets Global.
# ===============================================================
# 3. Microsoft Purview (CIS Section 3)
# ===============================================================
# NOTE: DLP, sensitivity labels, and retention policies are
# highly business-specific. Add them here as needed:
#
# purview:
# dlpPolicies:
# - name: "CIS-DLP-Default"
# ...
# ===============================================================
# 9. Power BI (CIS Section 9)
# ===============================================================
# NOTE: Power BI tenant settings are best managed via
# Microsoft365DSC or direct Admin API calls. Add here if needed.
# ===============================================================
# NEW in v7.0.0 (expected)
# ===============================================================
# As you read the draft PDF, transcribe new controls into the
# appropriate sections above. Use the 'cisControl' field to
# preserve traceability (e.g., cisControl: "5.2.3.7").
+237
View File
@@ -0,0 +1,237 @@
# CIS M365 v7.0.0 YAML Baseline Format
This document describes the YAML schema for `CISM365-v7.example.yaml`, which extends the existing `OpenIntuneBaseline.example.yaml` format to cover **tenant-level** M365 configuration.
## Why This Format?
The existing Intune baseline YAML works great for device policies. For CIS M365 compliance, you need the same declarative approach but for:
- Entra ID settings (password policies, device quotas, consent)
- Conditional Access policies
- Defender for Office 365 policies
- Exchange Online transport rules
- SharePoint / OneDrive sharing
- Microsoft Teams policies
This YAML keeps the **same root structure** as the Intune baseline so you can optionally include Intune policies in the same manifest, or keep them separate.
## Root Structure
```yaml
baseline:
name: string
conflictResolution: Skip | Update | Error
whatIf: false
tenantMutation:
search: string # optional
replace: string # optional
prefix: string # optional (alternative to search/replace)
groups: [] # Cloud-only security groups (same as Intune baseline)
policies: [] # Intune policies (optional, same schema as Intune baseline)
tenantConfig: # NEW: M365 tenant-level configuration
adminCenter: {}
entraId: {}
conditionalAccess: {}
defender: {}
exchange: {}
sharePoint: {}
teams: {}
```
## tenantConfig Sections
### adminCenter
M365 Admin Center settings.
```yaml
adminCenter:
passwordExpiration: NeverExpire # NeverExpire | 90Days | 180Days
idleSessionTimeoutHours: 3
restrictUserOwnedApps: true
formsPhishingProtection: true
customerLockbox: true
restrictThirdPartyStorage: true
```
### entraId
Entra ID directory settings.
```yaml
entraId:
blockUserConsent: true
blockTenantCreation: true
restrictAdminCenterAccess: true
disableLinkedIn: true
maxDevicesPerUser: 5
gaLocalAdminDisabled: true
bannedPasswords:
- "Contoso"
- "Password"
```
### conditionalAccess
The most powerful section. Supports **automatic CA policy creation** with:
- **Global `reportOnly` switch** — all policies default to report-only
- **Automatic break-glass exclusion** — specify one group, it's excluded from every policy
- **Custom naming** via `tenantMutation`
- **Role name resolution** — use friendly names like "Global Administrator", script maps to template IDs
```yaml
conditionalAccess:
reportOnly: true # Global switch
breakGlassGroup: "CIS-BreakGlass" # Auto-excluded from all policies
policies:
- name: "Block-Legacy-Auth"
cisControl: "5.2.2.3"
description: "Block legacy authentication"
state: enabledForReportingButNotEnforced
conditions:
applications:
includeApplications: ["All"]
users:
includeUsers: ["All"]
excludeGroups: ["CIS-Pilot-Users"]
clientAppTypes: ["exchangeActiveSync", "other"]
grantControls:
builtInControls: ["block"]
operator: "OR"
```
**CA Policy Conditions Supported:**
| Condition | YAML Key | Example |
|-----------|----------|---------|
| Apps | `applications.includeApplications` | `["All"]` or `["Office365"]` |
| User actions | `applications.includeUserActions` | `["urn:user:registersecurityinfo"]` |
| Users | `users.includeUsers` | `["All"]` or specific UPNs |
| Groups | `users.includeGroups` / `excludeGroups` | `["CIS-Pilot-Users"]` — resolved by displayName |
| Roles | `users.includeRoles` / `excludeRoles` | `["Global Administrator"]` — friendly names mapped to template IDs |
| Client apps | `clientAppTypes` | `["exchangeActiveSync", "other"]` |
| Sign-in risk | `signInRiskLevels` | `["medium", "high"]` |
| Locations | `locations.includeLocations` | `["AllTrusted"]` or `["All"]` |
| Auth flows | `authenticationFlows.deviceCodeFlow` | `{ isEnabled: true }` |
**Grant Controls Supported:**
| Control | YAML Key |
|---------|----------|
| Block | `grantControls.builtInControls: ["block"]` |
| Require MFA | `grantControls.builtInControls: ["mfa"]` |
| Compliant device | `grantControls.builtInControls: ["compliantDevice", "domainJoinedDevice"]` |
| Phishing-resistant MFA | `grantControls.builtInControls: ["authenticationStrength"]` + `grantControls.authenticationStrength.id` |
**Session Controls Supported:**
```yaml
sessionControls:
signInFrequency:
value: 12
type: hours
isEnabled: true
persistentBrowser:
mode: never # never | always
isEnabled: true
```
### defender
Defender for Office 365 policies.
```yaml
defender:
safeLinks:
- name: "SafeLinks-Default"
cisControl: "2.1.1"
enabled: true
trackClicks: true
allowClickThrough: false
scanUrls: true
enableForInternalSenders: true
safeAttachments:
- name: "SafeAttachments-Default"
cisControl: "2.1.4"
enabled: true
action: Block
quarantineMessages: true
antiMalware:
- name: "AntiMalware-Default"
cisControl: "2.1.2"
enabled: true
enableInternalNotifications: true
fileTypes: ["ace", "exe", "jar", "vbs"]
```
### exchange
Exchange Online settings.
```yaml
exchange:
enableMailboxAuditOrgWide: true
blockExternalForwarding: true
enableExternalSenderBanner: true
externalEmailWarningRule: true
```
### sharePoint
SharePoint / OneDrive sharing settings.
```yaml
sharePoint:
adminUrl: "https://contoso-admin.sharepoint.com"
defaultSharingLinkType: Direct
sharePointExternalSharing: Disabled
oneDriveExternalSharing: Disabled
guestAccessExpirationDays: 30
denyCustomScripts: true
```
### teams
Microsoft Teams policies.
```yaml
teams:
allowAnonymousUsersToJoinMeeting: false
allowAnonymousUsersToStartMeeting: false
enableEmailIntegration: false
allowFederatedUsers: false
allowTeamsConsumer: false
```
## Using the Draft PDF
Since CIS does not publish XLS for draft benchmarks:
1. Open the PDF and work through each section
2. For **automated** controls, add them to the appropriate `tenantConfig` section with the `cisControl` field
3. For **manual** controls, skip them or add a comment
4. The `cisControl` field preserves traceability (e.g., `cisControl: "5.2.2.3"`)
## Deployment
```powershell
# Assess (read-only)
./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath ./Baselines/mytenant-cisv7.yaml
# Deploy (applies changes)
./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath ./Baselines/mytenant-cisv7.yaml -Mode Deploy -Apply -Verbose
# Deploy only specific workloads
./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath ./Baselines/mytenant-cisv7.yaml -Mode Deploy -Apply -Workloads ConditionalAccess,EntraID
```
## Safety Defaults
| Feature | Default | Why |
|---------|---------|-----|
| `Mode` | `Assess` | Must explicitly opt in to changes |
| `conditionalAccess.reportOnly` | `true` | All CA policies created in report-only mode |
| `breakGlassGroup` | Auto-excluded | Prevents lockout |
| `Apply` switch | Required for Deploy | Double-confirmation pattern |
| `-WhatIf` | Supported | Native PowerShell WhatIf |
@@ -0,0 +1,234 @@
@{
# =====================================================================
# CIS M365 Rapid Baseline Configuration
# =====================================================================
# This file defines the desired state for a new/greenfield tenant.
# Edit values before running Deploy-CISM365RapidBaseline.ps1.
#
# IMPORTANT: This baseline is designed for NEW or NEWLY-ACQUIRED tenants.
# On an established tenant, some changes may impact users.
# =====================================================================
Tenant = @{
# Your tenant's initial .onmicrosoft.com domain
TenantDomain = 'contoso.onmicrosoft.com'
# SharePoint admin center URL
SharePointAdminUrl = 'https://contoso-admin.sharepoint.com'
# License profile: E3 | E5 | E3+P2
# Determines whether P2-only features (Identity Protection, PIM) are configured
LicenseProfile = 'E3'
}
# =====================================================================
# Section 5: Entra ID (Identity)
# =====================================================================
EntraID = @{
# 1.3.1 - Password expiration policy
PasswordExpiration = 'NeverExpire' # NeverExpire | 90Days | 180Days
# 5.2.3.2 - Custom banned password list
BannedPasswords = @('Contoso', 'Contoso1', 'Password', 'Welcome')
# 5.1.2.3 - Restrict non-admin users from creating tenants
BlockTenantCreation = $true
# 5.1.2.6 - Disable LinkedIn account connections
DisableLinkedIn = $true
# 5.1.2.2 - Disallow third-party integrated applications (user consent)
# Note: Set to $true for strict CIS compliance. May break some SaaS integrations.
BlockUserConsent = $true
# 5.1.4.2 - Maximum devices per user
MaxDevicesPerUser = 5
# 5.1.4.3 - Do not add GA role as local admin during Entra join
GALocalAdminDisabled = $true
# 5.2.3.1 - Microsoft Authenticator: protect against MFA fatigue
MFAFatigueProtection = $true
# Emergency access accounts (break-glass) - used for CA policy exclusions
BreakGlassAccounts = @(
'breakglass1@contoso.onmicrosoft.com'
'breakglass2@contoso.onmicrosoft.com'
)
}
# =====================================================================
# Section 5.2.2: Conditional Access Policies
# =====================================================================
ConditionalAccess = @(
@{
Name = 'CIS-Block-Legacy-Auth'
Description = 'CIS 5.2.2.3 - Block legacy authentication protocols'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
ClientAppTypes = @('exchangeActiveSync', 'other')
}
GrantControls = @{
BuiltInControls = @('block')
Operator = 'OR'
}
}
@{
Name = 'CIS-Require-MFA-Admins'
Description = 'CIS 5.2.2.1 - Require MFA for all users in administrative roles'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeRoles = @('62e90394-69f5-4237-9190-012177145e10') } # Exclude Global Admin if using PIM
}
GrantControls = @{
BuiltInControls = @('mfa')
Operator = 'OR'
}
}
@{
Name = 'CIS-Require-MFA-All-Users'
Description = 'CIS 5.2.2.2 - Require MFA for all users'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
Locations = @{ IncludeLocations = @('AllTrusted') } # Requires named locations
}
GrantControls = @{
BuiltInControls = @('mfa')
Operator = 'OR'
}
}
@{
Name = 'CIS-Block-Device-Code-Flow'
Description = 'CIS 5.2.2.12 - Block device code sign-in flow'
Enabled = $true
State = 'enabled'
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
AuthenticationFlows = @{ IncludeAuthenticationFlows = @('deviceCode') }
}
GrantControls = @{
BuiltInControls = @('block')
Operator = 'OR'
}
}
@{
Name = 'CIS-Block-High-Risk-SignIns'
Description = 'CIS 5.2.2.8 - Block sign-ins with medium/high risk (requires P2)'
Enabled = $true
State = 'enabledForReportingButNotEnforced' # Set to 'enabled' after validation
Conditions = @{
Applications = @{ IncludeApplications = @('All') }
Users = @{ IncludeUsers = @('All'); ExcludeUsers = @() }
SignInRiskLevels = @('high', 'medium')
}
GrantControls = @{
BuiltInControls = @('block')
Operator = 'OR'
}
}
)
# =====================================================================
# Section 2: Microsoft Defender for Office 365
# =====================================================================
Defender = @{
# 2.1.1 - Safe Links for Office Applications
SafeLinks = @{
Name = 'CIS-SafeLinks-Default'
Enabled = $true
TrackClicks = $true
AllowClickThrough = $false
ScanUrls = $true
EnableForInternalSenders = $true
}
# 2.1.4 - Safe Attachments
SafeAttachments = @{
Name = 'CIS-SafeAttachments-Default'
Enabled = $true
Action = 'Block' # Block | DynamicDelivery | Monitor
QuarantineMessages = $true
}
# 2.1.2 - Common Attachment Types Filter (built into anti-malware)
AntiMalware = @{
Name = 'CIS-AntiMalware-Default'
Enabled = $true
EnableInternalSenderNotifications = $true
FileTypes = @('ace', 'ani', 'app', 'docm', 'exe', 'iso', 'jar', 'jnlp', 'msi', 'php', 'ps1', 'scr', 'vbs', 'wsf')
}
# Anti-Phish baseline
AntiPhish = @{
Name = 'CIS-AntiPhish-Default'
Enabled = $true
EnableMailboxIntelligence = $true
EnableSpoofIntelligence = $true
MailboxIntelligenceProtectionAction = 'Quarantine'
TargetedUserProtectionAction = 'Quarantine'
TargetedDomainProtectionAction = 'Quarantine'
}
}
# =====================================================================
# Section 6: Exchange Online
# =====================================================================
Exchange = @{
# 6.2.1 - Block all forms of external mail forwarding
BlockExternalForwarding = $true
# 6.1.2 - Enable mailbox auditing organization-wide
EnableMailboxAudit = $true
# 6.2.3 - Identify email from external senders (external sender banner)
EnableExternalSenderBanner = $true
# Transport rule: prepend external email warning
ExternalEmailWarning = $true
}
# =====================================================================
# Section 7: SharePoint / OneDrive
# =====================================================================
SharePoint = @{
# 7.x - Default sharing link type
# Options: Direct, Internal, AnonymousAccess
DefaultSharingLinkType = 'Direct' # Most restrictive = Direct (specific people only)
# 7.x - External sharing for SharePoint
SharePointExternalSharing = 'Disabled' # Disabled | ExistingExternalUserSharingOnly | ExternalUserSharingOnly | Anyone
# 7.x - External sharing for OneDrive
OneDriveExternalSharing = 'Disabled' # Disabled | ExistingExternalUserSharingOnly | ExternalUserSharingOnly | Anyone
# Guest access expiration (days)
GuestAccessExpirationDays = 30
}
# =====================================================================
# Section 8: Microsoft Teams
# =====================================================================
Teams = @{
# 8.x - Allow anonymous users to join meetings
AllowAnonymousMeetingJoin = $false
# 8.x - Allow anonymous users to start meetings
AllowAnonymousMeetingStart = $false
# 8.x - Teams email integration
EnableEmailIntegration = $false
# Federation / external access
AllowFederatedUsers = $false
AllowTeamsConsumer = $false
}
}
@@ -0,0 +1,699 @@
<#PSScriptInfo
.VERSION 1.0.0
.GUID 9f3c2a8b-7e1d-4f5a-9b2c-8d3e4f5a6b7c
.AUTHOR IntuneManagement Toolkit
.COMPANYNAME
.COPYRIGHT
.TAGS CIS,M365,Security,Baseline,EntraID,Defender,Exchange,SharePoint,Teams
.LICENSEURI
.PROJECTURI
.ICONURI
.EXTERNALMODULEDEPENDENCIES Microsoft.Graph,ExchangeOnlineManagement,PnP.PowerShell,MicrosoftTeams
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
v1.0.0 - Initial rapid baseline for CIS M365 Foundations alignment on greenfield/newly-acquired tenants.
#>
<#
.SYNOPSIS
Rapidly deploys (or assesses) a high-impact CIS M365-aligned baseline to a new or newly-acquired tenant.
.DESCRIPTION
This script targets the ~40 highest-impact, easily-automated CIS M365 controls across:
- Entra ID (password policies, auth methods, Conditional Access)
- Microsoft Defender for Office 365 (Safe Links, Safe Attachments, Anti-Phish)
- Exchange Online (external forwarding block, mailbox auditing)
- SharePoint Online / OneDrive (external sharing restrictions)
- Microsoft Teams (anonymous meeting restrictions, federation)
It is designed for NEW or NEWLY-ACQUIRED tenants where disruption risk is low.
On established tenants, run in -Mode Assess first and review every change.
DEFAULT BEHAVIOUR IS READ-ONLY (-Mode Assess). You must specify -Mode Deploy -Apply to make changes.
.PARAMETER Mode
Assess = Read-only audit against the baseline (default)
Deploy = Apply the baseline configuration
.PARAMETER ConfigPath
Path to the .psd1 configuration file. Defaults to .\CISM365-RapidBaseline.psd1
.PARAMETER Apply
Required switch when Mode is 'Deploy'. Prevents accidental execution.
.PARAMETER TenantId
Optional tenant ID for Graph authentication.
.PARAMETER SharePointAdminUrl
Optional SharePoint admin URL (e.g., https://contoso-admin.sharepoint.com).
If omitted, uses the value from the config file.
.PARAMETER Workloads
Array of workloads to process. Default is all.
Options: EntraID, ConditionalAccess, Defender, Exchange, SharePoint, Teams
.EXAMPLE
# Assess your tenant without making any changes
.\Deploy-CISM365RapidBaseline.ps1
.EXAMPLE
# Deploy the baseline after review
.\Deploy-CISM365RapidBaseline.ps1 -Mode Deploy -Apply -Verbose
.EXAMPLE
# Assess only Entra ID and Conditional Access
.\Deploy-CISM365RapidBaseline.ps1 -Workloads @('EntraID','ConditionalAccess')
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter()]
[ValidateSet('Assess','Deploy')]
[string]$Mode = 'Assess',
[Parameter()]
[string]$ConfigPath = "$PSScriptRoot\CISM365-RapidBaseline.psd1",
[Parameter()]
[switch]$Apply,
[Parameter()]
[string]$TenantId,
[Parameter()]
[string]$SharePointAdminUrl,
[Parameter()]
[ValidateSet('EntraID','ConditionalAccess','Defender','Exchange','SharePoint','Teams')]
[string[]]$Workloads = @('EntraID','ConditionalAccess','Defender','Exchange','SharePoint','Teams')
)
#region Initialization
$ErrorActionPreference = 'Stop'
$script:Results = [System.Collections.Generic.List[object]]::new()
$script:ChangesMade = 0
$script:ChangesSkipped = 0
$script:Errors = 0
function Write-SectionHeader {
param([string]$Title)
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " $Title" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
}
function Add-Result {
param(
[string]$Workload,
[string]$Control,
[string]$Status, # Pass, Fail, Fixed, Skipped, Error
[string]$Message,
[string]$Remediation = ''
)
$script:Results.Add([PSCustomObject]@{
Workload = $Workload
Control = $Control
Status = $Status
Message = $Message
Remediation = $Remediation
})
switch ($Status) {
'Fixed' { $script:ChangesMade++ }
'Skipped' { $script:ChangesSkipped++ }
'Error' { $script:Errors++ }
}
}
# Load configuration
if (-not (Test-Path $ConfigPath)) {
throw "Configuration file not found: $ConfigPath"
}
$Config = Import-PowerShellDataFile -Path $ConfigPath
$TenantDomain = $Config.Tenant.TenantDomain
if (-not $SharePointAdminUrl) { $SharePointAdminUrl = $Config.Tenant.SharePointAdminUrl }
$LicenseProfile = $Config.Tenant.LicenseProfile
#endregion
#region Authentication
Write-SectionHeader "Authentication"
# Microsoft Graph
Write-Host "Connecting to Microsoft Graph..." -NoNewline
$GraphScopes = @(
'Directory.Read.All','Directory.ReadWrite.All','Policy.Read.All','Policy.ReadWrite.ConditionalAccess',
'Organization.Read.All','Organization.ReadWrite.All','RoleManagement.ReadWrite.Directory',
'IdentityRiskyUser.Read.All','IdentityRiskEvent.Read.All'
)
if ($TenantId) {
Connect-MgGraph -Scopes ($GraphScopes -join ',') -TenantId $TenantId -NoWelcome
} else {
Connect-MgGraph -Scopes ($GraphScopes -join ',') -NoWelcome
}
Write-Host " OK" -ForegroundColor Green
# Exchange Online (includes Defender)
if ($Workloads -contains 'Defender' -or $Workloads -contains 'Exchange') {
Write-Host "Connecting to Exchange Online..." -NoNewline
Connect-ExchangeOnline -ShowBanner:$false
Write-Host " OK" -ForegroundColor Green
}
# SharePoint
if ($Workloads -contains 'SharePoint') {
Write-Host "Connecting to SharePoint Online..." -NoNewline
Connect-PnPOnline -Url $SharePointAdminUrl -Interactive
Write-Host " OK" -ForegroundColor Green
}
# Teams
if ($Workloads -contains 'Teams') {
Write-Host "Connecting to Microsoft Teams..." -NoNewline
Connect-MicrosoftTeams
Write-Host " OK" -ForegroundColor Green
}
#endregion
#region Helper Functions
function Test-IsGlobalAdmin {
$context = Get-MgContext
$myRoles = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($context.Account)'" -ExpandProperty RoleDefinition
return ($myRoles.RoleDefinition.DisplayName -contains 'Global Administrator')
}
function Invoke-WithErrorHandling {
param(
[string]$Workload,
[string]$Control,
[scriptblock]$Action,
[string]$Remediation = ''
)
try {
& $Action
} catch {
Add-Result -Workload $Workload -Control $Control -Status 'Error' -Message $_.Exception.Message -Remediation $Remediation
Write-Warning "[$Workload/$Control] ERROR: $_"
}
}
#endregion
#region Entra ID
if ($Workloads -contains 'EntraID') {
Write-SectionHeader "Entra ID / Identity"
# 1.3.1 - Password expiration
Invoke-WithErrorHandling -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' -Action {
$org = Get-MgOrganization
$currentPolicy = $org.PasswordPolicies
$desired = if ($Config.EntraID.PasswordExpiration -eq 'NeverExpire') { 'None' } else { 'PasswordExpiration' }
if ($Mode -eq 'Assess') {
$pass = ($desired -eq 'None' -and $currentPolicy -contains 'DisablePasswordExpiration')
Add-Result -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' `
-Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "Current policy: $currentPolicy | Desired: $($Config.EntraID.PasswordExpiration)" `
-Remediation "Set-MgOrganization -PasswordPolicies 'DisablePasswordExpiration'"
} else {
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set password expiration to $($Config.EntraID.PasswordExpiration)")) {
Update-MgOrganization -OrganizationId $org.Id -PasswordPolicies 'DisablePasswordExpiration'
Add-Result -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' -Status 'Fixed' -Message "Set to NeverExpire"
} else {
Add-Result -Workload 'EntraID' -Control '1.3.1-PasswordExpiration' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
# 5.2.3.2 - Banned passwords
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Action {
$policy = Get-MgPolicyAuthenticationMethodPolicy | Select-Object -ExpandProperty AuthenticationMethodConfigurations | Where-Object { $_.Id -eq 'MicrosoftAuthenticator' }
# Banned password list is actually in directory settings
$settings = Get-MgDirectorySetting | Where-Object { $_.DisplayName -eq 'Password Rule Settings' }
if (-not $settings) {
$template = Get-MgDirectorySettingTemplate | Where-Object { $_.DisplayName -eq 'Password Rule Settings' }
$settings = New-MgDirectorySetting -TemplateId $template.Id
}
$currentList = ($settings.Values | Where-Object { $_.Name -eq 'BannedPasswordList' }).Value
$desiredList = $Config.EntraID.BannedPasswords -join ', '
if ($Mode -eq 'Assess') {
$hasAll = ($Config.EntraID.BannedPasswords | ForEach-Object { $currentList -contains $_ }) -notcontains $false
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' `
-Status $(if ($hasAll) { 'Pass' } else { 'Fail' }) `
-Message "Current: $currentList | Desired: $desiredList" `
-Remediation "Update-MgDirectorySetting -BannedPasswordList '$desiredList'"
} else {
if ($PSCmdlet.ShouldProcess($TenantDomain, "Update banned password list")) {
$params = @{ BannedPasswordList = $desiredList; EnableBannedPasswordCheck = $true }
Update-MgDirectorySetting -DirectorySettingId $settings.Id -Values $params
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Status 'Fixed' -Message "Updated banned password list"
} else {
Add-Result -Workload 'EntraID' -Control '5.2.3.2-BannedPasswords' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
# 5.1.2.3 - Block tenant creation by non-admins
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' -Action {
$setting = Get-MgPolicyAuthorizationPolicy
$current = $setting.DefaultUserRolePermissions.AllowedToCreateTenants
$desired = -not $Config.EntraID.BlockTenantCreation
if ($Mode -eq 'Assess') {
Add-Result -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' `
-Status $(if ($current -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "AllowedToCreateTenants = $current | Desired = $desired" `
-Remediation "Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{AllowedToCreateTenants=`$false}"
} else {
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set AllowedToCreateTenants = $desired")) {
Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateTenants = $desired }
Add-Result -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'EntraID' -Control '5.1.2.3-BlockTenantCreation' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
# 5.1.2.6 - Disable LinkedIn
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.6-DisableLinkedIn' -Action {
$org = Get-MgOrganization
$current = $org.MarketingNotificationEmails -contains 'LinkedIn'
# LinkedIn setting is in directory settings
$setting = Get-MgDirectorySetting | Where-Object { $_.DisplayName -eq 'Consent Policy Settings' }
# Simplified check - actual LinkedIn config varies by tenant region
Add-Result -Workload 'EntraID' -Control '5.1.2.6-DisableLinkedIn' -Status 'Skipped' `
-Message "LinkedIn integration check requires UI validation or tenant-specific Graph path." `
-Remediation "Navigate to Entra admin center > Users > User settings > LinkedIn account connections"
}
# 5.1.4.2 - Max devices per user
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' -Action {
$setting = Get-MgPolicyDeviceRegistrationPolicy
$current = $setting.UserDeviceQuota
$desired = $Config.EntraID.MaxDevicesPerUser
if ($Mode -eq 'Assess') {
Add-Result -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' `
-Status $(if ($current -le $desired) { 'Pass' } else { 'Fail' }) `
-Message "Current quota: $current | Desired max: $desired" `
-Remediation "Update-MgPolicyDeviceRegistrationPolicy -UserDeviceQuota $desired"
} else {
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set max devices per user to $desired")) {
Update-MgPolicyDeviceRegistrationPolicy -UserDeviceQuota $desired
Add-Result -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'EntraID' -Control '5.1.4.2-MaxDevicesPerUser' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
# 5.1.2.2 - Block user consent
Invoke-WithErrorHandling -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' -Action {
$policy = Get-MgPolicyAuthorizationPolicy
$current = $policy.DefaultUserRolePermissions.AllowedToCreateApps
$desired = -not $Config.EntraID.BlockUserConsent
if ($Mode -eq 'Assess') {
Add-Result -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' `
-Status $(if ($current -eq $desired) { 'Pass' } else { 'Fail' }) `
-Message "AllowedToCreateApps = $current | Desired = $desired" `
-Remediation "Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{AllowedToCreateApps=`$false}"
} else {
if ($PSCmdlet.ShouldProcess($TenantDomain, "Set AllowedToCreateApps = $desired")) {
Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateApps = $desired }
Add-Result -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' -Status 'Fixed' -Message "Set to $desired"
} else {
Add-Result -Workload 'EntraID' -Control '5.1.2.2-BlockUserConsent' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
}
#endregion
#region Conditional Access
if ($Workloads -contains 'ConditionalAccess') {
Write-SectionHeader "Conditional Access"
foreach ($caPolicy in $Config.ConditionalAccess) {
$policyName = $caPolicy.Name
Invoke-WithErrorHandling -Workload 'ConditionalAccess' -Control $policyName -Action {
$existing = Get-MgIdentityConditionalAccessPolicy -Filter "displayName eq '$policyName'" -ErrorAction SilentlyContinue
if ($Mode -eq 'Assess') {
if ($existing) {
$stateMatch = ($existing.State -eq $caPolicy.State)
Add-Result -Workload 'ConditionalAccess' -Control $policyName `
-Status $(if ($stateMatch) { 'Pass' } else { 'Fail' }) `
-Message "Policy exists. State: $($existing.State) | Desired: $($caPolicy.State)" `
-Remediation "Review policy in Entra admin center > Protection > Conditional Access"
} else {
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Fail' `
-Message "Policy does not exist." `
-Remediation "Create policy '$policyName' via Entra admin center or Graph API"
}
} else {
if ($existing) {
if ($PSCmdlet.ShouldProcess($policyName, "Update Conditional Access policy state to $($caPolicy.State)")) {
Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $existing.Id -State $caPolicy.State
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Fixed' -Message "Updated state to $($caPolicy.State)"
} else {
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
# For Deploy mode without existing policy, we provide guidance rather than auto-creating
# because CA policies are complex and tenant-specific (groups, apps, exclusions)
Add-Result -Workload 'ConditionalAccess' -Control $policyName -Status 'Skipped' `
-Message "Policy does not exist. Auto-creation of CA policies is intentionally manual to avoid lockouts." `
-Remediation "Use the sample JSON in this script's comments or build via Entra admin center, then re-run Assess."
}
}
}
}
}
#endregion
#region Defender / Exchange
if ($Workloads -contains 'Defender') {
Write-SectionHeader "Defender for Office 365"
# Safe Links
Invoke-WithErrorHandling -Workload 'Defender' -Control '2.1.1-SafeLinks' -Action {
$policy = Get-SafeLinksPolicy -Identity $Config.Defender.SafeLinks.Name -ErrorAction SilentlyContinue
if ($Mode -eq 'Assess') {
if ($policy) {
$pass = $policy.EnableSafeLinksForEmail -and $policy.TrackClicks -and -not $policy.AllowClickThrough
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "Safe Links policy exists. EmailProtection=$($policy.EnableSafeLinksForEmail) TrackClicks=$($policy.TrackClicks) AllowClickThrough=$($policy.AllowClickThrough)" `
-Remediation "Set-SafeLinksPolicy -Identity '$($Config.Defender.SafeLinks.Name)' -EnableSafeLinksForEmail `$true -TrackClicks `$true -AllowClickThrough `$false"
} else {
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Fail' `
-Message "Safe Links policy '$($Config.Defender.SafeLinks.Name)' not found." `
-Remediation "New-SafeLinksPolicy (see script comments for full syntax)"
}
} else {
if ($policy) {
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeLinks.Name, 'Update Safe Links policy')) {
Set-SafeLinksPolicy -Identity $Config.Defender.SafeLinks.Name `
-EnableSafeLinksForEmail $Config.Defender.SafeLinks.Enabled `
-TrackClicks $Config.Defender.SafeLinks.TrackClicks `
-AllowClickThrough $Config.Defender.SafeLinks.AllowClickThrough `
-ScanUrls $Config.Defender.SafeLinks.ScanUrls `
-EnableForInternalSenders $Config.Defender.SafeLinks.EnableForInternalSenders
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Fixed' -Message "Updated Safe Links policy"
} else {
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeLinks.Name, 'Create Safe Links policy')) {
New-SafeLinksPolicy -Name $Config.Defender.SafeLinks.Name `
-EnableSafeLinksForEmail $Config.Defender.SafeLinks.Enabled `
-TrackClicks $Config.Defender.SafeLinks.TrackClicks `
-AllowClickThrough $Config.Defender.SafeLinks.AllowClickThrough `
-ScanUrls $Config.Defender.SafeLinks.ScanUrls `
-EnableForInternalSenders $Config.Defender.SafeLinks.EnableForInternalSenders
# Create rule to apply it
New-SafeLinksRule -Name "$($Config.Defender.SafeLinks.Name)-Rule" -SafeLinksPolicy $Config.Defender.SafeLinks.Name -RecipientDomainIs (Get-AcceptedDomain).Name
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Fixed' -Message "Created Safe Links policy and rule"
} else {
Add-Result -Workload 'Defender' -Control '2.1.1-SafeLinks' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
}
# Safe Attachments
Invoke-WithErrorHandling -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Action {
$policy = Get-SafeAttachmentPolicy -Identity $Config.Defender.SafeAttachments.Name -ErrorAction SilentlyContinue
if ($Mode -eq 'Assess') {
if ($policy) {
$pass = $policy.Enable -and ($policy.Action -eq 'Block')
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "Safe Attachments exists. Enabled=$($policy.Enable) Action=$($policy.Action)" `
-Remediation "Set-SafeAttachmentPolicy -Identity '$($Config.Defender.SafeAttachments.Name)' -Enable `$true -Action Block"
} else {
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Fail' `
-Message "Policy not found." -Remediation "New-SafeAttachmentPolicy -Name '$($Config.Defender.SafeAttachments.Name)' -Enable `$true -Action Block"
}
} else {
if ($policy) {
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeAttachments.Name, 'Update Safe Attachments policy')) {
Set-SafeAttachmentPolicy -Identity $Config.Defender.SafeAttachments.Name `
-Enable $Config.Defender.SafeAttachments.Enabled -Action $Config.Defender.SafeAttachments.Action
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Fixed' -Message "Updated Safe Attachments policy"
} else {
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
if ($PSCmdlet.ShouldProcess($Config.Defender.SafeAttachments.Name, 'Create Safe Attachments policy')) {
New-SafeAttachmentPolicy -Name $Config.Defender.SafeAttachments.Name `
-Enable $Config.Defender.SafeAttachments.Enabled -Action $Config.Defender.SafeAttachments.Action
New-SafeAttachmentRule -Name "$($Config.Defender.SafeAttachments.Name)-Rule" `
-SafeAttachmentPolicy $Config.Defender.SafeAttachments.Name -RecipientDomainIs (Get-AcceptedDomain).Name
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Fixed' -Message "Created Safe Attachments policy and rule"
} else {
Add-Result -Workload 'Defender' -Control '2.1.4-SafeAttachments' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
}
# Anti-Malware (Common Attachment Types Filter)
Invoke-WithErrorHandling -Workload 'Defender' -Control '2.1.2-AntiMalware' -Action {
$policy = Get-MalwareFilterPolicy -Identity $Config.Defender.AntiMalware.Name -ErrorAction SilentlyContinue
if ($Mode -eq 'Assess') {
if ($policy) {
$pass = $policy.EnableInternalSenderNotifications
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "Anti-malware policy exists. InternalNotifications=$($policy.EnableInternalSenderNotifications)" `
-Remediation "Set-MalwareFilterPolicy -Identity '$($Config.Defender.AntiMalware.Name)' -EnableInternalSenderNotifications `$true"
} else {
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Fail' `
-Message "Policy not found." -Remediation "New-MalwareFilterPolicy -Name '$($Config.Defender.AntiMalware.Name)' -EnableInternalSenderNotifications `$true"
}
} else {
if ($policy) {
if ($PSCmdlet.ShouldProcess($Config.Defender.AntiMalware.Name, 'Update anti-malware policy')) {
Set-MalwareFilterPolicy -Identity $Config.Defender.AntiMalware.Name -EnableInternalSenderNotifications $true
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Fixed' -Message "Updated anti-malware policy"
} else {
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
if ($PSCmdlet.ShouldProcess($Config.Defender.AntiMalware.Name, 'Create anti-malware policy')) {
New-MalwareFilterPolicy -Name $Config.Defender.AntiMalware.Name -EnableInternalSenderNotifications $true
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Fixed' -Message "Created anti-malware policy"
} else {
Add-Result -Workload 'Defender' -Control '2.1.2-AntiMalware' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
}
}
}
}
if ($Workloads -contains 'Exchange') {
Write-SectionHeader "Exchange Online"
# 6.2.1 - Block external forwarding
Invoke-WithErrorHandling -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Action {
$rule = Get-TransportRule | Where-Object { $_.Name -like '*CIS*forward*' -or $_.Name -eq 'CIS-Block-External-Forwarding' }
if ($Mode -eq 'Assess') {
if ($rule) {
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Pass' `
-Message "Transport rule exists: $($rule.Name)"
} else {
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Fail' `
-Message "No transport rule blocking external forwarding." `
-Remediation "New-TransportRule -Name 'CIS-Block-External-Forwarding' -FromScope 'InOrganization' -SentToScope 'NotInOrganization' -RejectMessageReasonText 'External forwarding is disabled'"
}
} else {
if (-not $rule) {
if ($PSCmdlet.ShouldProcess('Transport Rule', 'Create external forwarding block')) {
New-TransportRule -Name 'CIS-Block-External-Forwarding' `
-FromScope 'InOrganization' -SentToScope 'NotInOrganization' `
-RejectMessageReasonText 'External forwarding is disabled per security policy.' `
-RejectMessageEnhancedStatusCode '5.7.1'
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Fixed' -Message "Created transport rule"
} else {
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
Add-Result -Workload 'Exchange' -Control '6.2.1-BlockExternalForwarding' -Status 'Pass' -Message "Rule already exists"
}
}
}
# 6.1.2 - Enable mailbox auditing
Invoke-WithErrorHandling -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Action {
$orgConfig = Get-OrganizationConfig
if ($Mode -eq 'Assess') {
$pass = $orgConfig.AuditDisabled -eq $false
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status $(if ($pass) { 'Pass' } else { 'Fail' }) `
-Message "AuditDisabled = $($orgConfig.AuditDisabled)" `
-Remediation "Set-OrganizationConfig -AuditDisabled `$false"
} else {
if ($orgConfig.AuditDisabled -ne $false) {
if ($PSCmdlet.ShouldProcess('Organization Config', 'Enable mailbox auditing')) {
Set-OrganizationConfig -AuditDisabled $false
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status 'Fixed' -Message "Enabled mailbox auditing"
} else {
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
Add-Result -Workload 'Exchange' -Control '6.1.2-MailboxAudit' -Status 'Pass' -Message "Already enabled"
}
}
}
}
#endregion
#region SharePoint
if ($Workloads -contains 'SharePoint') {
Write-SectionHeader "SharePoint / OneDrive"
Invoke-WithErrorHandling -Workload 'SharePoint' -Control '7.x-ExternalSharing' -Action {
$tenant = Get-PnPTenant
# SharePoint external sharing
$spoSharing = $tenant.SharingCapability
$desiredSpo = $Config.SharePoint.SharePointExternalSharing
if ($Mode -eq 'Assess') {
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' `
-Status $(if ($spoSharing -eq $desiredSpo) { 'Pass' } else { 'Fail' }) `
-Message "Current: $spoSharing | Desired: $desiredSpo" `
-Remediation "Set-PnPTenant -SharingCapability $desiredSpo"
} else {
if ($spoSharing -ne $desiredSpo) {
if ($PSCmdlet.ShouldProcess('SharePoint Tenant', "Set sharing to $desiredSpo")) {
Set-PnPTenant -SharingCapability $desiredSpo
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status 'Fixed' -Message "Set to $desiredSpo"
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-SharePointExternalSharing' -Status 'Pass' -Message "Already set to $desiredSpo"
}
}
# OneDrive external sharing
$odbSharing = $tenant.OneDriveSharingCapability
$desiredOdb = $Config.SharePoint.OneDriveExternalSharing
if ($Mode -eq 'Assess') {
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' `
-Status $(if ($odbSharing -eq $desiredOdb) { 'Pass' } else { 'Fail' }) `
-Message "Current: $odbSharing | Desired: $desiredOdb" `
-Remediation "Set-PnPTenant -OneDriveSharingCapability $desiredOdb"
} else {
if ($odbSharing -ne $desiredOdb) {
if ($PSCmdlet.ShouldProcess('OneDrive Tenant', "Set sharing to $desiredOdb")) {
Set-PnPTenant -OneDriveSharingCapability $desiredOdb
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status 'Fixed' -Message "Set to $desiredOdb"
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
Add-Result -Workload 'SharePoint' -Control '7.x-OneDriveExternalSharing' -Status 'Pass' -Message "Already set to $desiredOdb"
}
}
}
}
#endregion
#region Teams
if ($Workloads -contains 'Teams') {
Write-SectionHeader "Microsoft Teams"
Invoke-WithErrorHandling -Workload 'Teams' -Control '8.x-AnonymousMeetings' -Action {
$config = Get-CsTeamsMeetingConfiguration
$anonJoin = (Get-CsTeamsMeetingPolicy -Identity Global).AllowAnonymousUsersToJoinMeeting
$anonStart = (Get-CsTeamsMeetingPolicy -Identity Global).AllowAnonymousUsersToStartMeeting
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' `
-Status $(if ($anonJoin -eq $Config.Teams.AllowAnonymousMeetingJoin) { 'Pass' } else { 'Fail' }) `
-Message "AllowAnonymousUsersToJoinMeeting = $anonJoin | Desired = $($Config.Teams.AllowAnonymousMeetingJoin)" `
-Remediation "Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting `$false"
} else {
if ($anonJoin -ne $Config.Teams.AllowAnonymousMeetingJoin) {
if ($PSCmdlet.ShouldProcess('Teams Global Policy', 'Restrict anonymous meeting join')) {
Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $Config.Teams.AllowAnonymousMeetingJoin
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status 'Fixed' -Message "Set to $($Config.Teams.AllowAnonymousMeetingJoin)"
} else {
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
Add-Result -Workload 'Teams' -Control '8.x-AnonymousMeetingJoin' -Status 'Pass' -Message "Already set correctly"
}
}
}
Invoke-WithErrorHandling -Workload 'Teams' -Control '8.x-Federation' -Action {
$fedConfig = Get-CsTenantFederationConfiguration
if ($Mode -eq 'Assess') {
Add-Result -Workload 'Teams' -Control '8.x-Federation' `
-Status $(if ($fedConfig.AllowFederatedUsers -eq $Config.Teams.AllowFederatedUsers) { 'Pass' } else { 'Fail' }) `
-Message "AllowFederatedUsers = $($fedConfig.AllowFederatedUsers) | Desired = $($Config.Teams.AllowFederatedUsers)" `
-Remediation "Set-CsTenantFederationConfiguration -AllowFederatedUsers `$false"
} else {
if ($fedConfig.AllowFederatedUsers -ne $Config.Teams.AllowFederatedUsers) {
if ($PSCmdlet.ShouldProcess('Teams Federation', "Set AllowFederatedUsers to $($Config.Teams.AllowFederatedUsers)")) {
Set-CsTenantFederationConfiguration -AllowFederatedUsers $Config.Teams.AllowFederatedUsers
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status 'Fixed' -Message "Set to $($Config.Teams.AllowFederatedUsers)"
} else {
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status 'Skipped' -Message "WhatIf/Confirm declined"
}
} else {
Add-Result -Workload 'Teams' -Control '8.x-Federation' -Status 'Pass' -Message "Already set correctly"
}
}
}
}
#endregion
#region Report
Write-SectionHeader "Summary Report"
$passCount = ($script:Results | Where-Object { $_.Status -eq 'Pass' }).Count
$failCount = ($script:Results | Where-Object { $_.Status -eq 'Fail' }).Count
$fixedCount = $script:ChangesMade
$skippedCount = $script:ChangesSkipped
$errorCount = $script:Errors
Write-Host "Mode: $Mode" -ForegroundColor $(if ($Mode -eq 'Assess') { 'Green' } else { 'Yellow' })
Write-Host "Workloads: $($Workloads -join ', ')"
Write-Host ""
Write-Host "Results:"
Write-Host " Pass: $passCount" -ForegroundColor Green
Write-Host " Fail: $failCount" -ForegroundColor Red
if ($Mode -eq 'Deploy') {
Write-Host " Fixed: $fixedCount" -ForegroundColor Cyan
Write-Host " Skipped: $skippedCount" -ForegroundColor Yellow
}
Write-Host " Errors: $errorCount" -ForegroundColor $(if ($errorCount -gt 0) { 'Red' } else { 'Gray' })
Write-Host ""
# Export results
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$reportPath = "$PSScriptRoot\CISM365-RapidBaseline-Report_$Mode`_$timestamp.csv"
$script:Results | Export-Csv -Path $reportPath -NoTypeInformation -Force
Write-Host "Report saved to: $reportPath" -ForegroundColor Green
# Show failures if in Assess mode
if ($Mode -eq 'Assess' -and $failCount -gt 0) {
Write-Host "`nFailed checks:" -ForegroundColor Red
$script:Results | Where-Object { $_.Status -eq 'Fail' } | ForEach-Object {
Write-Host " [$($_.Workload)] $($_.Control): $($_.Message)" -ForegroundColor Red
if ($_.Remediation) { Write-Host " Remediation: $($_.Remediation)" -ForegroundColor DarkGray }
}
}
# Show errors
if ($errorCount -gt 0) {
Write-Host "`nErrors encountered:" -ForegroundColor Red
$script:Results | Where-Object { $_.Status -eq 'Error' } | ForEach-Object {
Write-Host " [$($_.Workload)] $($_.Control): $($_.Message)" -ForegroundColor Red
}
}
Write-Host "`nDone." -ForegroundColor Green
#endregion
+172
View File
@@ -0,0 +1,172 @@
# CIS M365 Rapid Baseline
> **Goal:** Take a new or newly-acquired tenant from zero to ~80% CIS M365 Foundations compliance in hours, not weeks.
Your existing `IntuneManagement` toolkit already handles **Section 4 (Intune)** of the CIS benchmark. This complements it with the tenant-level workloads: Entra ID, Conditional Access, Defender, Exchange, SharePoint, and Teams.
---
## The Reality Check
There is no single "Install-CIS-M365" command. The benchmark has **140 controls** across **9 sections**, and many are:
- **Assessment-only** (e.g., "Ensure 24 global admins exist" — a script can't decide who your admins should be)
- **License-dependent** (Identity Protection risk policies require Entra ID P2)
- **Tenant-specific** (Conditional Access exclusions, emergency access accounts, accepted domains)
**This baseline automates the ~40 highest-impact controls that are safe to script on a greenfield tenant.** The rest require human judgment.
---
## Prerequisites
```powershell
# PowerShell 7+ is strongly recommended
$PSVersionTable.PSVersion
# Install dependencies
Install-Module Microsoft.Graph -Scope CurrentUser -Force
Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force
Install-Module PnP.PowerShell -Scope CurrentUser -Force
Install-Module MicrosoftTeams -Scope CurrentUser -Force
```
**Permissions required:**
- Global Administrator (to create policies and grant consent)
- Or: combination of Privileged Role Administrator + Exchange Administrator + SharePoint Administrator + Teams Administrator
---
## The Fastest Path (Recommended Workflow)
### Step 0: Customize the config
Edit `CISM365-RapidBaseline.psd1`:
- Set your `TenantDomain` and `SharePointAdminUrl`
- Add your **break-glass emergency access accounts** to `BreakGlassAccounts`
- Adjust `ConditionalAccess` policies to reference your actual admin roles/groups
- Review `SharePointExternalSharing``Disabled` is most secure but may break planned collaboration
- Review `BlockUserConsent``true` is CIS-compliant but may break SaaS integrations
### Step 1: Assess (read-only)
```powershell
cd Baselines/M365-CIS-Rapid
# Default: assess everything, make zero changes
./Deploy-CISM365RapidBaseline.ps1
```
Review the CSV report. It tells you exactly what's wrong and how to fix it.
### Step 2: Deploy the easy wins
```powershell
# Deploy with WhatIf first (simulates changes without applying)
./Deploy-CISM365RapidBaseline.ps1 -Mode Deploy -WhatIf
# If satisfied, apply for real
./Deploy-CISM365RapidBaseline.ps1 -Mode Deploy -Apply -Verbose
```
### Step 3: Create Conditional Access policies manually
**This script intentionally does NOT auto-create Conditional Access policies.** CA misconfiguration can lock everyone out of the tenant, including you.
Use the assessment output as a checklist and create them in the Entra admin center:
1. **CIS-Block-Legacy-Auth** — Block all legacy auth protocols
2. **CIS-Require-MFA-Admins** — Require MFA for all admin roles
3. **CIS-Require-MFA-All-Users** — Require MFA for all users
4. **CIS-Block-Device-Code-Flow** — Block device code authentication
5. **CIS-Block-High-Risk-SignIns** — Block medium/high risk sign-ins (requires P2)
> **Pro tip:** Set new CA policies to `enabledForReportingButNotEnforced` for 24 hours before flipping to `enabled`. This lets you verify they don't block legitimate access.
### Step 4: Run a full CIS assessment
```powershell
# Install the comprehensive CIS assessment module
Install-Module CIS-M365-Benchmark -Scope CurrentUser -Force
Connect-CISM365Benchmark
Invoke-CISM365Benchmark -ProfileLevel L1 -ExcludeSections Intune
```
This checks all 140 controls and produces an HTML report with remediation steps for the remaining gaps.
### Step 5: Ongoing governance (optional but recommended)
For drift detection and continuous enforcement, introduce **Microsoft365DSC**:
```powershell
Install-Module Microsoft365DSC -Force
Update-M365DSCDependencies
# Export your now-hardened tenant as code
Export-M365DSCConfiguration -Workloads @("AAD","EXO","SPO","Teams") -Path ./m365-golden
```
Store that golden configuration in Git and run it through a pipeline weekly.
---
## What This Script Covers
| CIS Section | Controls Automated | Notes |
|-------------|-------------------|-------|
| **5.1** M365 Admin Center | Password expiration, tenant creation block, device quota, user consent | |
| **5.2.2** Conditional Access | Assessment only (safe by design) | Manual creation recommended |
| **5.2.3** Auth Methods | Banned password list | |
| **2.1** Defender | Safe Links, Safe Attachments, Anti-malware | Creates policy + rule |
| **6.1/6.2** Exchange | Mailbox auditing, external forwarding block | Transport rule |
| **7.x** SharePoint | External sharing restrictions | SPO + OneDrive |
| **8.x** Teams | Anonymous meeting restrictions, federation | Global policy |
**What it does NOT cover (requires human judgment):**
- Admin role assignments (how many GAs, who are they)
- Emergency access accounts (you must create these first)
- PIM configuration (requires P2, approval workflows)
- DMARC/DKIM/SPF records (DNS-level, not tenant-level)
- DLP policies (business-specific)
- Sensitivity labels (business-specific)
- Intune device policies (use your existing toolkit)
---
## Safety Features
- **`-Mode Assess` is the default.** Nothing changes unless you explicitly say `-Mode Deploy -Apply`.
- **`-WhatIf` is supported.** Use it to preview every change.
- **Break-glass exclusion.** The CA assessment template references `BreakGlassAccounts` — make sure these exist and are excluded from MFA/Compliance policies before enabling them.
- **Modular workloads.** Use `-Workloads` to target only one area at a time.
---
## Newly-Acquired vs. New Tenant
| Scenario | Approach |
|----------|----------|
| **Brand new tenant** (no users yet) | Run `-Mode Deploy -Apply` freely. Then create CA policies. |
| **Newly-acquired tenant** (has users, mailboxes, existing config) | Run `-Mode Assess` first. Review EVERY failed control for business impact before deploying. Some changes (e.g., disabling external sharing, blocking user consent) can break existing workflows. |
---
## Alternatives Considered
| Tool | Best For | Why We Didn't Use It As Primary |
|------|----------|--------------------------------|
| **Microsoft365DSC** | Long-term governance, drift detection | Learning curve is too high for "as fast as possible"; better introduced after initial hardening |
| **CISA ScubaGear** | Federal compliance, audit evidence | Read-only assessment; no deployment capability |
| **CIS-M365-Benchmark** | Comprehensive 140-control assessment | Read-only; excellent for gap analysis after rapid deployment |
| **Maester** | CI/CD testing, continuous validation | Read-only; great for pipelines, not initial deployment |
| **CoreView / Inforcer** | MSP multi-tenant deployment | Commercial; not applicable if you want open-source/scripted |
---
## Next Steps
1. Customize `CISM365-RapidBaseline.psd1`
2. Run assess mode
3. Deploy the easy wins
4. Create CA policies manually with reporting mode
5. Run `CIS-M365-Benchmark` for the remaining gaps
6. Introduce `Microsoft365DSC` for ongoing governance
+81
View File
@@ -0,0 +1,81 @@
baseline:
name: OpenIntuneBaseline-v3-Example
conflictResolution: Skip # Skip | Update | Error
whatIf: false
# Global name mutation applied to every policy (optional)
tenantMutation:
search: "OIB-"
replace: "CONTOSO-"
# Alternatively use prefix instead of search/replace:
# prefix: "CONTOSO-"
# Cloud-only security groups to create if they do not exist
groups:
- displayName: "Baseline - Windows Devices"
mailNickname: "BaselineWinDevices"
securityEnabled: true
- displayName: "Baseline - macOS Devices"
mailNickname: "BaselineMacDevices"
securityEnabled: true
- displayName: "Baseline - Pilot Users"
mailNickname: "BaselinePilotUsers"
securityEnabled: true
policies:
# Device Configuration
- sourcePath: ./policies/OIB-Windows-Defender-ASR.json
type: DeviceConfiguration
assignments:
- targetType: Group
groupName: "Baseline - Windows Devices"
# Settings Catalog (uses 'name' instead of displayName)
- sourcePath: ./policies/OIB-SettingsCatalog-LoginWindow.json
type: SettingsCatalog
# Per-policy mutation override
mutation:
search: "OIB-"
replace: "CONTOSO-"
assignments:
- targetType: Group
groupName: "Baseline - macOS Devices"
- targetType: AllDevices
# Compliance Policy
- sourcePath: ./policies/OIB-Compliance-Windows.json
type: CompliancePolicies
assignments:
- targetType: Group
groupName: "Baseline - Windows Devices"
# Endpoint Security (DeviceManagementIntents)
# If a sibling file *_Settings.json exists, it will be imported automatically.
- sourcePath: ./policies/OIB-EndpointSecurity-Defender.json
type: EndpointSecurity
assignments:
- targetType: Group
groupName: "Baseline - Windows Devices"
# Administrative Templates
- sourcePath: ./policies/OIB-ADMX-OfficeSettings.json
type: AdministrativeTemplates
assignments:
- targetType: Group
groupName: "Baseline - Pilot Users"
# macOS Script
- sourcePath: ./policies/OIB-MacScript-CompanyBranding.json
type: MacScripts
assignments:
- targetType: Group
groupName: "Baseline - macOS Devices"
# Application (metadata JSON only; .intunewin binary upload is NOT handled here)
- sourcePath: ./apps/OIB-CompanyPortal.json
type: Applications
assignments:
- targetType: AllUsers
intent: Available
- targetType: AllDevices
intent: Required
+26
View File
@@ -0,0 +1,26 @@
# CIS M365 v7 — Banned Passwords (external list)
# One password per line; lines starting with # are ignored
# These are merged with any inline bannedPasswords in the YAML baseline
# Common corporate names
Contoso
Fabrikam
Northwind
Wingtip
# Common weak passwords
Password
Welcome
Admin
Login
Passw0rd
Qwerty
123456
# Microsoft / Office branding
Microsoft
Office365
Outlook
Azure
Teams
SharePoint
Binary file not shown.
Binary file not shown.
+215
View File
@@ -0,0 +1,215 @@
# macOS Intune Toolkit Changelog
## 2026-08-07 — v4.4.1 — Reorder TUI menu into logical groups
### Changed
- **`Start-IntuneToolkit.ps1`**
- Menu had grown by prepending each new feature to the top, so items ended up in reverse-add order with no relation between neighbors. Reordered into groups — Export/Import, Bulk operations, Baselines & compliance, Reporting, Tenant & auth admin — with Exit last. Item numbers (used for dispatch) are unchanged, only display order moved, so no other script or doc references needed updating.
## 2026-08-07 — v4.4.0 — Folder browser for Export/Import path prompts
### Added
- **`Scripts/Private/Start-IntuneManagementTui.ps1`**
- New `Select-FolderPath` helper: when `fzf` is available, the Export/Import root folder prompt (and the report flow's export/backup-root/output-dir prompts) becomes an interactive folder browser — navigate into subfolders, go up with `..`, confirm the current folder, or type a path manually. `Read-Host` alone has no tab-completion (that's a top-level PSReadLine feature, not available to script prompts), so this reuses the `fzf` dependency already required for menus instead of building custom completion.
- Falls back to a plain `Read-Host` prompt when `fzf` isn't installed — no regression from prior behavior.
## 2026-08-07 — v4.3.1 — Fix redundant action prompt
### Fixed
- **`Scripts/Private/Start-IntuneManagementTui.ps1`**
- Choosing **1. Export policies** or **2. Import policies** in the launcher still re-asked "Select action" (Export/Import/DeployCISBaseline/GenerateReports) — the same missing-passthrough issue as the tenant re-prompt fix below, just for `-Action`. Now accepts `-Action` and skips the prompt when it's already supplied.
- **`Start-IntuneToolkit.ps1`** / **`Scripts/Start-HeadlessIntune.ps1`**
- Menu items 1/2 now pass `-Action Export` / `-Action Import` through to the headless script and on to the TUI.
## 2026-08-07 — v4.3.0 — Bulk delete tool and TUI fixes
### Added
- **`Scripts/Bulk-DeletePolicies.ps1`**
- Select object type, filter by name, multi-select objects, and delete them from the tenant.
- Requires typing `DELETE` to confirm since the operation is irreversible. Supports `-WhatIf` dry-run.
- Wired into `Start-IntuneToolkit.ps1` as menu item **21. Bulk delete policies**.
### Fixed
- **`Start-IntuneToolkit.ps1`**
- Fixed an infinite loop when `fzf` is not installed: the numbered-menu fallback returned `"EXIT"` for the tenant-selection Exit option, but the check only matched the fzf path's `"[Exit]"` string, so choosing Exit was silently ignored and the tenant became the literal string `"EXIT"`.
- **`Scripts/Private/Start-IntuneManagementTui.ps1`**
- The interactive Export/Import/GenerateReports/DeployCISBaseline flow always re-prompted for a Tenant ID even when the tenant was already selected in the outer launcher, because it was invoked with no parameters and had an empty `param()` block. It now accepts `-TenantId` and skips its three tenant prompts when the value is already supplied.
- Clarified the "Name filter" prompt text — it's a plain case-insensitive substring match, not a regex (the underlying match escapes the value via `[RegEx]::Escape()`), so the old `'^Win-OIB-'` example was misleading.
- **`Scripts/Start-HeadlessIntune.ps1`**
- Passes `-TenantId` through to `Start-IntuneManagementTui.ps1` so the fix above takes effect.
## 2026-06-23 — v4.2.0 — Entra directory role membership export
### Added
- **`Scripts/Export-EntraRoleMembership.ps1`**
- Exports all active and PIM-eligible Microsoft Entra directory role memberships to CSV.
- Expands group-assigned roles to transitive group members.
- Marks whether each role is privileged.
- Supports the toolkit's standard `-AuthMode` (`AppOnly`, `Browser`, `DeviceCode`) and reads saved credentials from `Settings.json`/macOS Keychain.
- **`Start-IntuneToolkit.ps1`**
- Added menu item **20. Export Entra role membership** that launches the new script and prompts for the CSV output path.
- **`README.md`**
- Listed `Scripts/Export-EntraRoleMembership.ps1` in the entry points and documented its purpose.
### Repository hygiene
- **`.gitignore`**
- Ignore the local `accounts/` folder and stray operational shell scripts (`deploy.sh`, `restart_gateways.sh`).
---
## 2026-06-22 — Expanded settings report coverage and Conditional Access documentation
### Added
- **`Scripts/Export-SettingsReport.py`**
- Greatly expanded coverage: Settings Catalog, Compliance Policies V2, Endpoint Security, Device Management Intents, Administrative Templates, Device Configuration (with OMA-URI expansion), Compliance Policies V1, PowerShell/Shell/Compliance/Health scripts (base64-decoded previews), App Protection, App Configuration, Update Policies, Enrollment restrictions, Autopilot, W365, Filters, and more.
- Added dedicated processors for intent-style settings (`_Settings.json` companion files) and ADMX definition values.
- Added base64 decoding for script content with a configurable preview length.
- **`Scripts/Invoke-ConditionalAccessDocumentation.ps1`**
- New menu-driven Conditional Access policy documentation script (menu item **19** in `Start-IntuneToolkit.ps1`).
- Exports CA policies to CSV and optionally Excel, resolving object IDs to display names.
- **`Start-IntuneToolkit.ps1`**
- Added menu item **19. Document Conditional Access policies**.
### Fixed
- **`.gitignore`**
- Ignore `Scripts/ConditionalAccessDocumentation.csv` and `.xlsx` output files so tenant-specific documentation is not committed by accident.
---
## 2026-06-14 — Auto-export Settings Catalog definitions for report resolution
### Added
- **`Extensions/EndpointManager.psm1`**
- `Start-PostExportSettingsCatalog` now auto-exports `/deviceManagement/configurationSettings` to `<backup-root>/configurationSettings.json` the first time a Settings Catalog policy is exported.
- New helper `Start-ExportSettingsCatalogDefinitions` fetches all pages of setting definitions and writes them next to the policy folders.
- 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.
---
## 2026-04-16 — v4.1.0 — Accountability, PIM & Auth Management
### Modified
- **`Scripts/Initialize-IntuneAuth.ps1`**
- App registrations are now named after the **authenticated Entra user** (e.g., `IntuneManagement-tomas.kracmar@cqre.net`) instead of the local OS username. This improves audit-log traceability when multiple admins use the toolkit against the same tenant.
- Added `-Delete` switch to remove local tenant credentials (`Settings.json` + macOS Keychain) without touching the Entra app registration.
- Added `-DeleteApp` switch to delete both the **Entra app registration** and local credentials.
- Onboarding now automatically caches the tenant display name after auth setup, so the TUI shows friendly names immediately.
- Added `Organization.Read.All` to the `Connect-MgGraph` scopes to support tenant name caching.
- **`Scripts/Start-IntuneToolkit.ps1`**
- Added menu items **14** (delete local auth) and **15** (delete auth + app registration) to the TUI.
- Selecting **"[+ Onboard new tenant]"** now runs the auth initializer immediately and restarts the launcher, instead of dropping into the main menu for an unconfigured tenant.
- The TUI now exits cleanly after deleting tenant auth.
- **`README.md`**
- Added **Accountability & PIM caveats** section explaining the trade-offs of app-only auth versus delegated auth, and how app naming affects audit logs.
---
## 2026-04-13 — API Permissions Sync for `Initialize-IntuneAuth.ps1`
### Modified
- **`Scripts/Initialize-IntuneAuth.ps1`**
- Unified the required Microsoft Graph application permissions into a single `$requiredRoles` list defined before app creation/reuse logic:
- `DeviceManagementApps.ReadWrite.All`
- `DeviceManagementConfiguration.ReadWrite.All`
- `DeviceManagementManagedDevices.ReadWrite.All`
- `DeviceManagementScripts.ReadWrite.All`
- `DeviceManagementServiceConfig.ReadWrite.All`
- `DeviceManagementRBAC.ReadWrite.All`
- `Group.ReadWrite.All`
- `Directory.Read.All`
- `User.Read.All`
- `Organization.Read.All`
- `Policy.ReadWrite.ConditionalAccess`
- `Agreement.ReadWrite.All`
- `CloudPC.ReadWrite.All`
- `Application.Read.All`
- **Existing app patching**: When reusing an existing app registration, the script now inspects its current `RequiredResourceAccess`. If any required permissions are missing, it patches the app via `Update-MgApplication`, refreshes the local app object, and the downstream admin-consent loop automatically grants consent for the newly added roles.
---
## Prior delivered changes (context summary)
### New scripts added
- `Scripts/Bulk-AppAssignment.ps1` — bulk-assign apps to groups/All Users/All Devices
- `Scripts/Bulk-AssignmentManager.ps1` — add/remove assignments for any policy type using correct `@odata.type` and bulk `/assign` endpoint
- `Scripts/Backup-Restore-Assignments.ps1` — JSON backup with cross-tenant group name resolution
- `Scripts/Export-AssignmentsToCsv.ps1` — CSV and Markdown documentation output
- `Scripts/Bulk-RenamePolicies.ps1` — search/replace, add/strip prefix across displayName/description
- `Scripts/Bulk-DeviceOperations.ps1` — delete/retire/wipe/lock/sync with `-WhatIf` safeguards
- `Scripts/Start-IntuneToolkit.ps1` — unified reverse-numbered `fzf`-based launcher
- `Scripts/Initialize-IntuneAuth.ps1` — one-time Entra app + secret + Keychain setup
### Core / Extensions / Headless changes
- **`Extensions/MSGraph.psm1`**
- `Invoke-GraphRequest` now throws on 4xx/5xx HTTP errors (was silently returning null)
- Added `-AllPages` support to `Get-GraphObjects` and toolkit queries for large tenants
- **`Headless/IntuneManagement.Headless.psm1`**
- Expanded `Get-DefaultIntunePolicyObjectTypes` to ~45 types, including `DeviceManagementIntents`
- Threaded `NameSearchPattern` / `NameReplacePattern` through export/import/action flows
- **Settings Catalog fixes**
- Uses `name` property instead of `displayName` for queries/labels
- Assignments use `#microsoft.graph.deviceManagementConfigurationPolicyAssignment` and the bulk `POST …/assign` endpoint
- **TUI / `fzf`**
- Spacebar toggle, Esc to go back, reverse numbering (10→1) in unified launcher
## 2026-04-13 — Declarative Baseline Deployer
### Added
- **`Scripts/Deploy-IntuneBaseline.ps1`**
- YAML-driven one-click deployment of Intune policies + assignments to new tenants.
- Supports global and per-policy name mutations (`search`/`replace` or `prefix`).
- Auto-creates cloud-only security groups if missing.
- Idempotent imports with configurable conflict resolution (`Skip`, `Update`, `Error`).
- Full `-WhatIf` dry-run support.
- Handles 20+ policy types including Settings Catalog (`name` property), EndpointSecurity (settings file companion upload), and Applications.
- Integrates with existing auth stack (Settings.json / macOS Keychain).
- **`Scripts/ConvertTo-IntuneBaseline.ps1`**
- Converts an existing toolkit export folder into a baseline YAML skeleton.
- Maps folder names to baseline types, extracts display names, and generates empty assignment blocks.
- **`Baselines/OpenIntuneBaseline.example.yaml`**
- Example manifest demonstrating groups, mutations, policies, and assignments.
### Dependencies
- `powershell-yaml` module (auto-install prompt if missing).
## 2026-04-13 — Unified Launcher: Baseline Deployer Integration
### Modified
- **`Scripts/Start-IntuneToolkit.ps1`**
- Added menu entries for baseline deployment:
- `10. Deploy baseline`
- `11. Deploy baseline (dry-run / WhatIf)`
- Forwards `-WhatIf` switch correctly when dry-run option is selected.
- Ensures `WhatIf` flag is cleared between loop iterations to avoid leakage to other tools.
+42
View File
@@ -0,0 +1,42 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Identity.Client;
public class HttpFactoryWithProxy : IMsalHttpClientFactory
{
private static HttpClient _httpClient;
public HttpFactoryWithProxy(string proxyURI) : this(proxyURI, null, null)
{
}
public HttpFactoryWithProxy(string proxyURI, string proxyUserName = null, string proxyPassword = null)
{
if (_httpClient == null)
{
var proxy = new WebProxy
{
Address = new Uri(proxyURI),
BypassProxyOnLocal = false,
UseDefaultCredentials = false,
Credentials = new NetworkCredential(
userName: proxyUserName,
password: proxyPassword)
};
var httpClientHandler = new HttpClientHandler
{
Proxy = proxy,
};
_httpClient = new HttpClient(handler: httpClientHandler);
}
}
public HttpClient GetHttpClient()
{
return _httpClient;
}
}
+57
View File
@@ -0,0 +1,57 @@
// Updated original code from
// Added support for custom file location
// https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-token-cache-serialization#simple-token-cache-serialization-msal-only
using System;
using System.IO;
using System.Security.Cryptography;
using Microsoft.Identity.Client;
public static class TokenCacheHelperEx
{
public static void EnableSerialization(ITokenCache tokenCache, String fileName = @"%LOCALAPPDATA%\GraphPowerShellManager\MSALToken.bin")
{
tokenCache.SetBeforeAccess(BeforeAccessNotification);
tokenCache.SetAfterAccess(AfterAccessNotification);
CacheFilePath = Environment.ExpandEnvironmentVariables(fileName);
}
/// <summary>
/// Path to the token cache
/// </summary>
public static string CacheFilePath { get; private set;}
private static readonly object FileLock = new object();
private static void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
lock (FileLock)
{
args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
null,
DataProtectionScope.CurrentUser)
: null);
}
}
private static void AfterAccessNotification(TokenCacheNotificationArgs args)
{
// if the access operation resulted in a cache update
if (args.HasStateChanged)
{
lock (FileLock)
{
Directory.CreateDirectory(Path.GetDirectoryName(CacheFilePath));
// reflect changes in the persistent store
File.WriteAllBytes(CacheFilePath,
ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
null,
DataProtectionScope.CurrentUser)
);
}
}
}
}
+1050
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
@{
RootModule = 'IntuneManagement.Headless.psm1'
ModuleVersion = '0.1.0'
GUID = 'b5b4183d-8d6b-4b31-bbde-f2f0f0a0739d'
Author = 'OpenAI Codex'
Copyright = '(c) OpenAI. Adapter module for headless Intune policy migration.'
Description = 'Headless export/import wrapper for IntuneManagement.'
FunctionsToExport = @(
'Get-DefaultIntunePolicyObjectTypes',
'Export-IntunePolicies',
'Import-IntunePolicies',
'Invoke-IntunePolicyAction'
)
AliasesToExport = @()
VariablesToExport = @()
CmdletsToExport = @()
}
+469
View File
@@ -0,0 +1,469 @@
$script:coreModulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1"
if (Test-Path $script:coreModulePath)
{
Import-Module $script:coreModulePath -Force
}
function Get-DefaultIntunePolicyObjectTypes
{
@(
"ScopeTags",
"AssignmentFilters",
"ReusableSettings",
"RoleDefinitions",
"Notifications",
"DeviceHealthScripts",
"ComplianceScripts",
"PowerShellScripts",
"MacScripts",
"MacCustomAttributes",
"ADMXFiles",
"IntuneBranding",
"AzureBranding",
"TermsAndConditions",
"TermsOfUse",
"EnrollmentStatusPage",
"EnrollmentRestrictions",
"AppleEnrollmentTypes",
"AutoPilot",
"AndroidOEMConfig",
"DeviceCategories",
"AuthenticationStrengths",
"AuthenticationContext",
"NamedLocations",
"ConditionalAccess",
"CoManagementSettings",
"Applications",
"AppProtection",
"AppConfigurationManagedApp",
"AppConfigurationManagedDevice",
"UpdatePolicies",
"FeatureUpdates",
"QualityUpdates",
"DriverUpdateProfiles",
"HardwareConfigurations",
"InventoryPolicies",
"W365ProvisioningPolicies",
"W365UserSettings",
"AdministrativeTemplates",
"DeviceConfiguration",
"SettingsCatalog",
"CompliancePolicies",
"CompliancePoliciesV2",
"EndpointSecurity",
"DeviceManagementIntents",
"PolicySets"
)
}
function Get-DefaultBrowserAppId
{
"14d82eec-204b-4c2f-b7e8-296a70dab67e"
}
function Get-IntuneManagementProjectRoot
{
Split-Path -Parent $PSScriptRoot
}
function Resolve-HeadlessSettingsPath
{
param([string]$SettingsFile)
if($SettingsFile)
{
return $SettingsFile
}
# Default to the persistent data folder (same location used by Initialize-IntuneAuth)
Join-Path (Get-CloudApiDataFolder) "Settings.json"
}
function New-TemporaryBatchFile
{
param([string]$Prefix)
Join-Path ([IO.Path]::GetTempPath()) ("IntuneManagement.{0}.{1}.json" -f $Prefix, [guid]::NewGuid().ToString())
}
function Test-AuthParameters
{
param(
[string]$AuthMode,
[string]$AppId,
[string]$Secret,
[string]$Certificate
)
if($AuthMode -eq "Browser" -or $AuthMode -eq "DeviceCode")
{
return
}
if(-not $AppId)
{
throw "Specify -AppId for AppOnly auth."
return
}
if((-not $Secret) -and (-not $Certificate))
{
throw "Specify -Secret or -Certificate for AppOnly auth, or use -AuthMode Browser."
}
}
function Invoke-IntuneHeadlessBatch
{
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)]
[psobject]$BatchConfig,
[string]$SettingsFile,
[string]$BatchFile
)
if(($AuthMode -eq "Browser" -or $AuthMode -eq "DeviceCode") -and -not $AppId)
{
$AppId = Get-DefaultBrowserAppId
}
# Pre-load settings to fill missing AppId/Secret before auth validation
$settingsPath = Resolve-HeadlessSettingsPath $SettingsFile
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or -not $Secret -and -not $Certificate))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
# macOS Keychain fallback for secret
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
Test-AuthParameters -AuthMode $AuthMode -AppId $AppId -Secret $Secret -Certificate $Certificate
$projectRoot = Get-IntuneManagementProjectRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$deleteBatchFile = $false
if(-not $BatchFile)
{
$BatchFile = New-TemporaryBatchFile "Batch"
$deleteBatchFile = $true
}
try
{
$BatchConfig | ConvertTo-Json -Depth 20 | Out-File -LiteralPath $BatchFile -Encoding utf8 -Force
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
SilentBatchFile = $BatchFile
AuthMode = $AuthMode
}
if($RedirectUri)
{
$invokeParams.RedirectUri = $RedirectUri
}
if($AuthMode -eq "AppOnly" -and $Secret)
{
$invokeParams.Secret = $Secret
}
elseif($AuthMode -eq "AppOnly")
{
$invokeParams.Certificate = $Certificate
}
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
}
finally
{
if($deleteBatchFile -and (Test-Path $BatchFile))
{
Remove-Item -LiteralPath $BatchFile -Force -ErrorAction SilentlyContinue
}
}
}
function Export-IntunePolicies
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)]
[string]$ExportPath,
[string]$SettingsFile,
[string]$BatchFile,
[string]$NameFilter = "",
[string]$NameSearchPattern = "",
[string]$NameReplacePattern = "",
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
[switch]$IncludeAssignments,
[switch]$AddCompanyName
)
$batchConfig = [PSCustomObject]@{
BulkExport = @(
[PSCustomObject]@{ Name = "txtExportPath"; Value = $ExportPath },
[PSCustomObject]@{ Name = "txtExportNameFilter"; Value = $NameFilter },
[PSCustomObject]@{ Name = "txtExportNameSearchPattern"; Value = $NameSearchPattern },
[PSCustomObject]@{ Name = "txtExportNameReplacePattern"; Value = $NameReplacePattern },
[PSCustomObject]@{ Name = "chkAddObjectType"; Value = $true },
[PSCustomObject]@{ Name = "chkExportAssignments"; Value = $IncludeAssignments.IsPresent },
[PSCustomObject]@{ Name = "chkAddCompanyName"; Value = $AddCompanyName.IsPresent },
[PSCustomObject]@{ Name = "ObjectTypes"; Type = "Custom"; ObjectTypes = @($ObjectTypes) }
)
}
Invoke-IntuneHeadlessBatch `
-TenantId $TenantId `
-AppId $AppId `
-Secret $Secret `
-Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-BatchConfig $batchConfig `
-SettingsFile $SettingsFile `
-BatchFile $BatchFile
}
function Import-IntunePolicies
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)]
[string]$ImportPath,
[string]$SettingsFile,
[string]$BatchFile,
[string]$NameFilter = "",
[string]$NameSearchPattern = "",
[string]$NameReplacePattern = "",
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
[string]$ImportType = "alwaysImport",
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
[switch]$IncludeAssignments,
[switch]$IncludeScopeTags,
[switch]$ReplaceDependencyIds
)
$batchConfig = [PSCustomObject]@{
BulkImport = @(
[PSCustomObject]@{ Name = "txtImportPath"; Value = $ImportPath },
[PSCustomObject]@{ Name = "txtImportNameFilter"; Value = $NameFilter },
[PSCustomObject]@{ Name = "txtImportNameSearchPattern"; Value = $NameSearchPattern },
[PSCustomObject]@{ Name = "txtImportNameReplacePattern"; Value = $NameReplacePattern },
[PSCustomObject]@{ Name = "chkAddObjectType"; Value = $true },
[PSCustomObject]@{ Name = "chkImportScopes"; Value = $IncludeScopeTags.IsPresent },
[PSCustomObject]@{ Name = "chkImportAssignments"; Value = $IncludeAssignments.IsPresent },
[PSCustomObject]@{ Name = "chkReplaceDependencyIDs"; Value = $ReplaceDependencyIds.IsPresent },
[PSCustomObject]@{ Name = "cbImportType"; Value = $ImportType },
[PSCustomObject]@{ Name = "ObjectTypes"; Type = "Custom"; ObjectTypes = @($ObjectTypes) }
)
}
Invoke-IntuneHeadlessBatch `
-TenantId $TenantId `
-AppId $AppId `
-Secret $Secret `
-Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-BatchConfig $batchConfig `
-SettingsFile $SettingsFile `
-BatchFile $BatchFile
}
function Invoke-IntunePolicyAction
{
[CmdletBinding(DefaultParameterSetName = 'Export')]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("Export","Import")]
[string]$Action,
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile,
[string]$BatchFile,
[string]$NameFilter = "",
[string]$NameSearchPattern = "",
[string]$NameReplacePattern = "",
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
[string]$ExportPath,
[string]$ImportPath,
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
[string]$ImportType = "alwaysImport",
[switch]$IncludeAssignments,
[switch]$AddCompanyName,
[switch]$IncludeScopeTags,
[switch]$ReplaceDependencyIds
)
switch($Action)
{
"Export"
{
if(-not $ExportPath) { throw "Export requires -ExportPath." }
Export-IntunePolicies `
-TenantId $TenantId `
-AppId $AppId `
-Secret $Secret `
-Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-ExportPath $ExportPath `
-SettingsFile $SettingsFile `
-BatchFile $BatchFile `
-NameFilter $NameFilter `
-NameSearchPattern $NameSearchPattern `
-NameReplacePattern $NameReplacePattern `
-ObjectTypes $ObjectTypes `
-IncludeAssignments:$IncludeAssignments `
-AddCompanyName:$AddCompanyName
}
"Import"
{
if(-not $ImportPath) { throw "Import requires -ImportPath." }
Import-IntunePolicies `
-TenantId $TenantId `
-AppId $AppId `
-Secret $Secret `
-Certificate $Certificate `
-AuthMode $AuthMode `
-RedirectUri $RedirectUri `
-ImportPath $ImportPath `
-SettingsFile $SettingsFile `
-BatchFile $BatchFile `
-NameFilter $NameFilter `
-NameSearchPattern $NameSearchPattern `
-NameReplacePattern $NameReplacePattern `
-ImportType $ImportType `
-ObjectTypes $ObjectTypes `
-IncludeAssignments:$IncludeAssignments `
-IncludeScopeTags:$IncludeScopeTags `
-ReplaceDependencyIds:$ReplaceDependencyIds
}
}
}
+35
View File
@@ -0,0 +1,35 @@
# Headless Runtime
This folder contains the reusable CLI module for the repo.
## Module
* [IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1)
* [IntuneManagement.Headless.psm1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psm1)
## Exported commands
* `Get-DefaultIntunePolicyObjectTypes`
* `Export-IntunePolicies`
* `Import-IntunePolicies`
* `Invoke-IntunePolicyAction`
## Example
```powershell
Import-Module ./Headless/IntuneManagement.Headless.psd1
Export-IntunePolicies `
-TenantId "<source-tenant-id>" `
-AppId "<app-id>" `
-Secret "<client-secret>" `
-ExportPath "/tmp/intune-export"
```
```powershell
Export-IntunePolicies `
-TenantId "<source-tenant-id>" `
-AuthMode Browser `
-RedirectUri "http://localhost" `
-ExportPath "/tmp/intune-export"
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Mikael Karlsson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+147
View File
@@ -0,0 +1,147 @@
# Agent Operations Log
## Session: 2026-04-13T13:31:37+02:00
### Task
Update `Initialize-IntuneAuth.ps1` to ensure the Entra app registration is granted all Microsoft Graph permissions required by the expanded macOS Intune Toolkit, and log all operations.
---
## Operations performed
1. **ReadFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
- Line offset: `140`, Lines: `60`
- Purpose: Inspect the existing roles array and app creation logic.
2. **StrReplaceFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
- Change: Replaced the inline `$roles` array + app creation block with a unified `$requiredRoles` list and added logic to patch missing permissions into an existing app registration via `Update-MgApplication`.
- Impact: New apps get the full permission set; existing apps are updated if roles are missing.
3. **Grep**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
- Pattern: `New-MgServicePrincipalAppRoleAssignment`
- Purpose: Verify the downstream admin-consent loop would pick up updated roles.
4. **ReadFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
- Line offset: `220`, Lines: `50`
- Purpose: Inspect the consent-granting region around `New-MgServicePrincipalAppRoleAssignment`.
5. **Grep**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1`
- Pattern: `\$roles`
- Purpose: Confirm no stale `$roles` references remained after the refactor.
6. **Shell**
- Command: `pwsh -Command "$ErrorActionPreference='Stop'; Get-Command /Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1 | Out-Null; Write-Host 'Syntax OK'"`
- Result: Syntax OK (PowerShell parsed the script without errors).
7. **Shell**
- Command: `cd /Users/avedelphina/Local/IntuneManagement && git diff -- Scripts/Initialize-IntuneAuth.ps1`
- Result: No diff output (file is untracked in git).
8. **Shell**
- Command: `cd /Users/avedelphina/Local/IntuneManagement && git status --short`
- Result: Listed modified and untracked files, confirming `Scripts/Initialize-IntuneAuth.ps1` is `??` (untracked).
9. **WriteFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/CHANGELOG_macOS_IntuneToolkit.md`
- Content: High-level changelog of the permissions update and prior context.
10. **WriteFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/OPERATIONS_LOG.md`
- Content: This file.
---
## Files modified in this session
- `Scripts/Initialize-IntuneAuth.ps1`
## Files created in this session
- `CHANGELOG_macOS_IntuneToolkit.md`
- `OPERATIONS_LOG.md`
## Session: 2026-04-13 (continuation)
### Task
Build a declarative Intune baseline deployment system (YAML manifest + deployer script + converter helper).
---
## Operations performed
1. **ReadFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Bulk-AssignmentManager.ps1`
- Purpose: Studied auth bootstrap pattern, `$assignableTypes` registry, and assignment payload format.
2. **ReadFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Extensions/MSGraph.psm1`
- Purpose: Verified `Invoke-GraphRequest` parameter names (`Content` alias `Body`, `HttpMethod` alias `Method`).
3. **WriteFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Baselines/OpenIntuneBaseline.example.yaml`
- Content: Example baseline manifest with groups, mutations, and policy assignments.
4. **WriteFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Deploy-IntuneBaseline.ps1`
- Content: Main deployer script (~550 lines) with YAML parsing, group creation, policy import/update, mutation, idempotency, and assignment deployment.
5. **WriteFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/ConvertTo-IntuneBaseline.ps1`
- Content: Helper script that scans an export folder and emits a baseline YAML skeleton.
6. **Shell**
- Commands: Syntax validation (`Get-Command`) on both new scripts.
- Result: Both scripts parsed successfully.
7. **WriteFile (append)**
- Path: `/Users/avedelphina/Local/IntuneManagement/CHANGELOG_macOS_IntuneToolkit.md`
- Added changelog entry for the baseline deployer feature.
8. **WriteFile (append)**
- Path: `/Users/avedelphina/Local/IntuneManagement/OPERATIONS_LOG.md`
- Added this very entry.
## Files created in this session
- `Baselines/OpenIntuneBaseline.example.yaml`
- `Scripts/Deploy-IntuneBaseline.ps1`
- `Scripts/ConvertTo-IntuneBaseline.ps1`
## Session: 2026-04-13 (continuation 2)
### Task
Add the new `Deploy-IntuneBaseline.ps1` to the unified launcher menu, with separate dry-run and write entries.
---
## Operations performed
1. **ReadFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Start-IntuneToolkit.ps1`
- Purpose: Inspect menu array, switch mapping, and parameter forwarding logic.
2. **StrReplaceFile**
- Path: `/Users/avedelphina/Local/IntuneManagement/Scripts/Start-IntuneToolkit.ps1`
- Changes:
- Expanded `$menuItems` from 10→0 to 12→0, inserting `Deploy baseline (dry-run / WhatIf)` and `Deploy baseline`.
- Added `switch` cases for `10` and `11` mapping to `Scripts/Deploy-IntuneBaseline.ps1`.
- Added `$commonParams.Remove("WhatIf")` in the cleanup block to prevent flag leakage.
3. **Shell**
- Command: Syntax validation on `Start-IntuneToolkit.ps1`.
- Result: Syntax OK.
4. **WriteFile (append)**
- Path: `/Users/avedelphina/Local/IntuneManagement/CHANGELOG_macOS_IntuneToolkit.md`
- Added launcher integration changelog entry.
5. **WriteFile (append)**
- Path: `/Users/avedelphina/Local/IntuneManagement/OPERATIONS_LOG.md`
- Added this operations entry.
## Files modified in this session
- `Scripts/Start-IntuneToolkit.ps1`
+171 -1
View File
@@ -1,2 +1,172 @@
# macOS_IntuneManagement
# macOS Intune Management
Cross-platform, headless Intune policy export/import with PowerShell.
**Current version:** `4.4.1` — see [`CHANGELOG_macOS_IntuneToolkit.md`](CHANGELOG_macOS_IntuneToolkit.md) for recent changes.
This repository is now CLI-first. The old WPF application surface has been removed from the repo. The supported workflow is:
1. export policies from a source tenant
2. store the exported JSON and migration table
3. import into a target tenant with app-only or browser authentication
## Quick start
The easiest way to get started is the unified launcher. It provides a single terminal UI for every tool and remembers your tenants.
```powershell
pwsh ./Start-IntuneToolkit.ps1
```
If `fzf` is installed you get an interactive picker; otherwise you get a numbered menu. You can also pass a tenant directly:
```powershell
pwsh ./Start-IntuneToolkit.ps1 -TenantId "<tenant-id>"
```
## Entry points
* [Start-IntuneToolkit.ps1](/Users/avedelphina/Local/IntuneManagement/Start-IntuneToolkit.ps1) — unified launcher (recommended)
* [Scripts/Start-HeadlessIntune.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Start-HeadlessIntune.ps1) — single action wrapper with optional TUI
* [Scripts/Export-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-Policies.ps1)
* [Scripts/Import-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Import-Policies.ps1)
* [Scripts/Initialize-IntuneAuth.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1) — one-time Entra app + secret + Keychain setup
* [Scripts/Export-SettingsReport.py](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-SettingsReport.py) — generate a flat CSV of policy settings/values
* [Scripts/Export-EntraRoleMembership.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-EntraRoleMembership.ps1) — export all active + PIM-eligible Entra directory role memberships (group assignments expanded) to CSV
* [Headless/IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1)
## Runtime
* `pwsh` 7+
* Microsoft Graph app registration
* App-only auth with client secret or certificate, or browser auth with a public client redirect URI
* `fzf` (optional) — for the best interactive menu experience in `Start-IntuneToolkit.ps1`. Falls back to numbered menus if not installed.
* macOS: `brew install fzf`
* Linux: `sudo apt install fzf` (or `dnf` / `pacman`)
* Windows: `winget install junegunn.fzf` (or `choco install fzf`)
## Default object types
The default headless policy scope is:
* `DeviceConfiguration`
* `SettingsCatalog`
* `AdministrativeTemplates`
* `CompliancePolicies`
* `EndpointSecurity`
* `PolicySets`
You can override that list with `-ObjectTypes`.
## First-time setup
If you don't already have an Entra app registration, run the auth initializer. It creates the app, grants admin consent, and stores the secret in the macOS Keychain (or Windows Credential Manager).
```powershell
pwsh ./Scripts/Initialize-IntuneAuth.ps1
```
## Export
```powershell
pwsh ./Scripts/Export-Policies.ps1 `
-TenantId "<source-tenant-id>" `
-AppId "<app-id>" `
-Secret "<client-secret>" `
-ExportPath "/tmp/intune-export" `
-IncludeAssignments
```
## Export with browser auth
```powershell
pwsh ./Scripts/Export-Policies.ps1 `
-TenantId "<source-tenant-id>" `
-AuthMode Browser `
-ExportPath "/tmp/intune-export"
```
## Import
```powershell
pwsh ./Scripts/Import-Policies.ps1 `
-TenantId "<target-tenant-id>" `
-AppId "<app-id>" `
-Secret "<client-secret>" `
-ImportPath "/tmp/intune-export/SourceTenantName" `
-ImportType alwaysImport `
-IncludeAssignments `
-IncludeScopeTags `
-ReplaceDependencyIds
```
## Import with browser auth
```powershell
pwsh ./Scripts/Import-Policies.ps1 `
-TenantId "<target-tenant-id>" `
-AuthMode Browser `
-ImportPath "/tmp/intune-export/SourceTenantName"
```
## Single action entry point
```powershell
pwsh ./Scripts/Start-HeadlessIntune.ps1 `
-Action Export `
-TenantId "<source-tenant-id>" `
-AppId "<app-id>" `
-Secret "<client-secret>" `
-ExportPath "/tmp/intune-export"
```
```powershell
pwsh ./Scripts/Start-HeadlessIntune.ps1 `
-Action Import `
-TenantId "<target-tenant-id>" `
-AppId "<app-id>" `
-Secret "<client-secret>" `
-ImportPath "/tmp/intune-export/SourceTenantName" `
-ImportType alwaysImport
```
```powershell
pwsh ./Scripts/Start-HeadlessIntune.ps1 `
-Action Export `
-TenantId "<source-tenant-id>" `
-AuthMode Browser `
-RedirectUri "http://localhost" `
-ExportPath "/tmp/intune-export"
```
## Additional toolkit scripts
* **Baseline deployment** — [`Deploy-IntuneBaseline.ps1`](Scripts/Deploy-IntuneBaseline.ps1) deploys a YAML manifest of policies + assignments to a tenant, with dry-run support. [`ConvertTo-IntuneBaseline.ps1`](Scripts/ConvertTo-IntuneBaseline.ps1) turns an existing export folder into a baseline skeleton.
* **CIS M365 baseline** — [`Deploy-CISM365Baseline.ps1`](Scripts/Deploy-CISM365Baseline.ps1) applies the CIS Microsoft 365 v7 benchmark to a tenant. See [`Baselines/M365-CIS-Rapid/`](Baselines/M365-CIS-Rapid/) for a config-driven rapid baseline.
* **Bulk assignments** — [`Bulk-AssignmentManager.ps1`](Scripts/Bulk-AssignmentManager.ps1) adds or removes assignments for any policy type using the bulk `/assign` endpoint. [`Bulk-AppAssignment.ps1`](Scripts/Bulk-AppAssignment.ps1) does the same for applications.
* **Backup / restore assignments** — [`Backup-Restore-Assignments.ps1`](Scripts/Backup-Restore-Assignments.ps1) saves assignments to JSON and can restore them with cross-tenant group name resolution.
* **Bulk rename** — [`Bulk-RenamePolicies.ps1`](Scripts/Bulk-RenamePolicies.ps1) performs search/replace or prefix mutations across policy names and descriptions.
* **Bulk delete** — [`Bulk-DeletePolicies.ps1`](Scripts/Bulk-DeletePolicies.ps1) deletes selected policies/apps by object type and name filter, requires typing `DELETE` to confirm, supports `-WhatIf`.
* **Device operations** — [`Bulk-DeviceOperations.ps1`](Scripts/Bulk-DeviceOperations.ps1) supports delete, retire, wipe, lock, and sync with `-WhatIf` safeguards.
* **Assignment documentation** — [`Export-AssignmentsToCsv.ps1`](Scripts/Export-AssignmentsToCsv.ps1) exports assignments to CSV and Markdown.
* **Reporting utilities** — [`Export-SettingsReport.py`](Scripts/Export-SettingsReport.py), [`Export-AssignmentReport.py`](Scripts/Export-AssignmentReport.py), and [`Export-ObjectInventoryReport.py`](Scripts/Export-ObjectInventoryReport.py) generate CSV/Markdown reports from local exports.
* **Baseline batch runner** — [`Invoke-BaselineBatch.ps1`](Scripts/Invoke-BaselineBatch.ps1) run multiple baseline manifests in one pass.
* **Conditional Access wizard** — [`Start-CAWizard.ps1`](Scripts/Start-CAWizard.ps1) / [`ca-wizard.py`](Scripts/ca-wizard.py) generate Conditional Access baseline skeletons.
## Notes
* Export writes a migration table used during cross-tenant import.
* Import can translate dependency IDs and recreate missing assignment groups.
* This repo intentionally does not preserve the old Windows UI launch flow.
* Browser auth uses the system browser and a loopback redirect.
* If you omit `-AppId` with `-AuthMode Browser`, the CLI defaults to the Microsoft Graph PowerShell public client app id `14d82eec-204b-4c2f-b7e8-296a70dab67e`.
* If your own app registration does not allow loopback redirects, pass `-AppId` and `-RedirectUri "http://localhost"` and configure the same redirect URI in Entra ID.
## Accountability & PIM caveats
By default `Initialize-IntuneAuth.ps1` creates an **app-only** registration. Every Graph call is authenticated as the service principal, not as an individual user.
* **Audit logs** show the app's display name (e.g., `IntuneManagement-tomas.kracmar@cqre.net`), not the admin's UPN. The initializer now automatically names the app after the **authenticated Entra user** to improve traceability.
* **PIM is not enforced** for app-only secrets. The service principal has standing permissions, so write operations can occur outside an elevated PIM window.
* If you need strict PIM compliance, use **delegated authentication** (`-AuthMode Browser` or `-AuthMode DeviceCode`) so calls are made in the signed-in user's context. Note that `DeviceCode` may be blocked by Conditional Access policies.
* To fully remove a tenant's local credentials **and** the Entra app registration, use menu item **15** in the TUI or run `./Scripts/Initialize-IntuneAuth.ps1 -TenantId "<id>" -DeleteApp`.
+1380
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
@{
RootModule = 'IntuneManagement.Runtime.psm1'
ModuleVersion = '4.0.0'
GUID = 'c7aa4c71-d00d-44bc-9c09-b4741e7435ab'
Author = 'Mikael Karlsson'
Copyright = '(c) 2026 Mikael Karlsson. Software released under MIT License.'
Description = 'Headless Intune policy export and import runtime'
FunctionsToExport = @('Initialize-IntuneManagementRuntime', 'Test-IsWindowsPlatform', 'Expand-FileName')
AliasesToExport = @()
ModuleList = @('IntuneManagement.Runtime.psm1')
PrivateData = @{
PSData = @{
Tags = @('Intune','IntuneManagement','Microsoft Graph','PowerShell','CLI','Headless')
}
}
}
+110
View File
@@ -0,0 +1,110 @@
function Test-IsWindowsPlatform
{
[Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT
}
function Expand-FileName
{
param([string]$Path)
if(-not $Path) { return $Path }
$expanded = [Environment]::ExpandEnvironmentVariables($Path)
if($expanded -like "~/*" -or $expanded -eq "~")
{
$expanded = $expanded -replace "^~", $HOME
}
return $expanded
}
function Initialize-IntuneManagementRuntime
{
[CmdletBinding()]
param(
[string]$View = "",
[switch]$ShowConsoleWindow,
[switch]$JSonSettings,
[string]$JSonFile,
[switch]$Silent,
[string]$SilentBatchFile,
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$GraphEnvironment,
[string]$GCCType
)
$PSModuleAutoloadingPreference = "none"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$global:hideUI = $true
$global:SilentBatchFile = $SilentBatchFile
$global:TenantId = $TenantId
$global:AzureAppId = $AppId
$global:ClientSecret = $Secret
$global:ClientCert = $Certificate
$global:HeadlessAuthMode = $AuthMode
$global:MSALRedirectUri = $RedirectUri
$global:UseGraphEnvironment = $GraphEnvironment
$global:UseGCCType = $GCCType
$global:UseJSonSettings = ($JSonSettings -eq $true)
$global:JSonSettingFile = $JSonFile
if(-not $Silent)
{
Write-Warning "UI support has been removed. Continuing in headless mode."
}
if(-not $global:TenantId)
{
Write-Error "Tenant Id is missing. Use -TenantId <Tenant-guid>."
return
}
if($global:TenantId)
{
Write-Host "Using Tenant Id: $($global:TenantId)"
}
if($global:AzureAppId)
{
Write-Host "Using Azure App Id: $($global:AzureAppId)"
}
else
{
Write-Warning "Azure App Id is missing. Use -AppId <AppId>."
}
if($global:ClientSecret)
{
Write-Host "Using Azure App Secret"
}
elseif($global:ClientCert)
{
Write-Host "Using Azure App Certificate"
}
elseif($global:HeadlessAuthMode -eq "Browser")
{
Write-Host "Using browser authentication"
}
elseif($global:HeadlessAuthMode -eq "DeviceCode")
{
Write-Host "Using device code authentication"
}
else
{
Write-Warning "Azure App Secret or Certificate is missing. Use -Secret <Secret> or -Certificate <Certificate>."
}
if($global:UseJSonSettings)
{
Write-Host "Use json settings"
}
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1") -Force -Global
Start-CoreApp $View
}
Export-ModuleMember -Function Initialize-IntuneManagementRuntime, Test-IsWindowsPlatform, Expand-FileName
+523
View File
@@ -0,0 +1,523 @@
#requires -Version 5.1
<#
.SYNOPSIS
Backup and restore Intune policy/app assignments.
.DESCRIPTION
Backs up assignments for selected object types to a JSON file,
or restores assignments from a previously created backup.
Works cross-platform (macOS/Linux/Windows) using the headless auth stack.
.EXAMPLE
# Backup
./Scripts/Backup-Restore-Assignments.ps1 -TenantId "..." -Mode Backup -OutputPath ./backups/assignments-backup.json
# Restore
./Scripts/Backup-Restore-Assignments.ps1 -TenantId "..." -Mode Restore -InputPath ./backups/assignments-backup.json
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[ValidateSet("Backup","Restore")]
[string]$Mode,
[string]$OutputPath,
[string]$InputPath,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Read-YesNo
{
param(
[string]$Prompt,
[bool]$Default = $false
)
$defaultChar = if($Default) { "Y" } else { "N" }
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
return $response -match "^\s*y"
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Validate paths
if($Mode -eq "Backup" -and -not $OutputPath)
{
throw "Backup mode requires -OutputPath."
}
if($Mode -eq "Restore" -and -not $InputPath)
{
throw "Restore mode requires -InputPath."
}
if($Mode -eq "Restore" -and -not (Test-Path $InputPath))
{
throw "Input file not found: $InputPath"
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
#region Object type registry
$assignableTypes = @(
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; HasIntent = $true; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "name" },
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; HasIntent = $false; NameProp = "displayName" }
)
#endregion
#region BACKUP
if($Mode -eq "Backup")
{
$typeTitles = $assignableTypes | ForEach-Object { $_.Title }
$selectedTypeTitles = Select-MenuItem -Items $typeTitles -Header "Select object types to back up (multi-select)" -Multi
if(-not $selectedTypeTitles)
{
Write-Host "No types selected. Exiting." -ForegroundColor Yellow
exit 0
}
# Preload groups for name resolution in backup
Write-Host "`nLoading groups for backup resolution..." -ForegroundColor Cyan
$backupGroupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
$backupGroups = @{}
foreach($g in $backupGroupsResponse.value)
{
$backupGroups[$g.id] = $g.displayName
}
$backupData = @{
TenantId = $org.value[0].id
TenantName = $org.value[0].displayName
Created = (Get-Date -Format "o")
Groups = $backupGroups
Objects = @()
}
foreach($typeTitle in $selectedTypeTitles)
{
$objectType = $assignableTypes | Where-Object { $_.Title -eq $typeTitle } | Select-Object -First 1
Write-Host "`nBacking up $($objectType.Title) assignments..." -ForegroundColor Cyan
try
{
$objectsResponse = Invoke-GraphRequest "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
$objects = $objectsResponse.value | Where-Object { $_ }
Write-Host " Found $($objects.Count) objects" -ForegroundColor Green
foreach($obj in $objects)
{
try
{
$assignmentsResponse = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
$assignments = $assignmentsResponse.value
if($assignments.Count -gt 0)
{
# Enrich assignments with group display names for cross-tenant restore
$enrichedAssignments = $assignments | ConvertTo-Json -Depth 50 | ConvertFrom-Json
foreach($ass in $enrichedAssignments)
{
if($ass.target.groupId -and $backupGroups.ContainsKey($ass.target.groupId))
{
$ass.target | Add-Member -NotePropertyName "_backupGroupName" -NotePropertyValue $backupGroups[$ass.target.groupId] -Force
}
}
$backupData.Objects += [PSCustomObject]@{
ObjectType = $objectType.Title
ObjectId = $obj.id
ObjectName = if($objectType.NameProp -eq "name") { $obj.name } else { $obj.displayName }
NameProp = $objectType.NameProp
API = $objectType.API
AssignmentsType = $objectType.AssignmentsType
Assignments = $enrichedAssignments
}
}
}
catch
{
Write-Host " WARNING: Could not backup assignments for $($obj."$($objectType.NameProp)")" -ForegroundColor DarkYellow
}
}
}
catch
{
Write-Host " WARNING: Could not load objects for $($objectType.Title)" -ForegroundColor DarkYellow
}
}
$backupJson = $backupData | ConvertTo-Json -Depth 50
$OutputPath = (Resolve-Path (Split-Path -Parent $OutputPath) -ErrorAction SilentlyContinue).Path + "/" + (Split-Path -Leaf $OutputPath)
$backupJson | Out-File -LiteralPath $OutputPath -Encoding utf8 -Force
$totalAssignments = 0
foreach($obj in $backupData.Objects) { $totalAssignments += $obj.Assignments.Count }
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Backup Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " File : $OutputPath"
Write-Host " Objects : $($backupData.Objects.Count)"
Write-Host " Assignments : $totalAssignments"
}
#endregion
#region RESTORE
elseif($Mode -eq "Restore")
{
$backup = Get-Content $InputPath -Raw | ConvertFrom-Json
Write-Host "`nBackup info:" -ForegroundColor Cyan
Write-Host " Tenant : $($backup.TenantName) ($($backup.TenantId))"
Write-Host " Created: $($backup.Created)"
Write-Host " Objects: $($backup.Objects.Count)"
$currentTenantId = $org.value[0].id
if($backup.TenantId -ne $currentTenantId)
{
Write-Host "`nWARNING: Backup is from a different tenant!" -ForegroundColor Yellow
if(-not (Read-YesNo -Prompt "Continue anyway?" -Default $false))
{
Write-Host "Cancelled." -ForegroundColor Yellow
exit 0
}
}
# Resolve group names to IDs in current tenant if needed
Write-Host "`nLoading current tenant groups for name resolution..." -ForegroundColor Cyan
$currentGroupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
$currentGroups = $currentGroupsResponse.value
$success = 0
$skipped = 0
$failed = 0
foreach($entry in $backup.Objects)
{
Write-Host "`nRestoring: $($entry.ObjectName) ($($entry.ObjectType))" -ForegroundColor Cyan
# Try to find the object in current tenant by displayName
$nameProp = ?? $entry.NameProp "displayName"
$searchUrl = "$($entry.API)?`$filter=$nameProp eq '$([uri]::EscapeDataString($entry.ObjectName))'&`$select=id,$nameProp"
try
{
$searchResult = Invoke-GraphRequest $searchUrl
$targetObj = $searchResult.value | Select-Object -First 1
}
catch
{
$targetObj = $null
}
if(-not $targetObj)
{
Write-Host " SKIP: Object '$($entry.ObjectName)' not found in current tenant" -ForegroundColor DarkYellow
$failed++
continue
}
# Load existing assignments to avoid duplicates
try
{
$existing = Invoke-GraphRequest "$($entry.API)/$($targetObj.id)/assignments"
$existingTargets = $existing.value
}
catch
{
Write-Host " ERROR: Could not load existing assignments" -ForegroundColor Red
$failed++
continue
}
function Test-BackupAssignmentExists
{
param($assignment, $existingList)
$t = $assignment.target
foreach($ea in $existingList)
{
$et = $ea.target
if($t."@odata.type" -ne $et."@odata.type") { continue }
if($t.groupId -and $t.groupId -ne $et.groupId) { continue }
# Also match intent for apps
if($entry.AssignmentsType -eq "mobileAppAssignments" -and ($assignment.intent -ne $ea.intent)) { continue }
return $true
}
return $false
}
foreach($assignment in $entry.Assignments)
{
# Clone assignment to avoid modifying backup data
$restoredAssignment = $assignment | ConvertTo-Json -Depth 50 | ConvertFrom-Json
# Remove Id
if($restoredAssignment.PSObject.Properties["id"])
{
$restoredAssignment.PSObject.Properties.Remove("id")
}
# Map group IDs if cross-tenant
if($backup.TenantId -ne $currentTenantId -and $restoredAssignment.target.groupId)
{
$originalGroupName = $restoredAssignment.target."_backupGroupName"
if($originalGroupName)
{
$matchedGroup = $currentGroups | Where-Object { $_.displayName -eq $originalGroupName } | Select-Object -First 1
if($matchedGroup)
{
Write-Host " MAPPED: Group '$originalGroupName' -> $($matchedGroup.id)" -ForegroundColor Gray
$restoredAssignment.target.groupId = $matchedGroup.id
}
else
{
Write-Host " SKIP: Could not find group '$originalGroupName' in current tenant" -ForegroundColor DarkYellow
$skipped++
continue
}
}
else
{
Write-Host " SKIP: Cross-tenant restore cannot resolve group without name mapping" -ForegroundColor DarkYellow
$skipped++
continue
}
}
# Clean up internal property before sending
if($restoredAssignment.target.PSObject.Properties["_backupGroupName"])
{
$restoredAssignment.target.PSObject.Properties.Remove("_backupGroupName")
}
if(Test-BackupAssignmentExists -assignment $restoredAssignment -existingList $existingTargets)
{
Write-Host " SKIP: Assignment already exists" -ForegroundColor DarkYellow
$skipped++
continue
}
# Prepare payload
$payload = @{
$entry.AssignmentsType = @($restoredAssignment)
}
try
{
$body = $payload | ConvertTo-Json -Depth 50 -Compress
$null = Invoke-GraphRequest "$($entry.API)/$($targetObj.id)/assign" -HttpMethod POST -Content $body
Write-Host " OK: Restored assignment" -ForegroundColor Green
$success++
}
catch
{
Write-Host " ERROR: Failed to restore assignment. $($_.Exception.Message)" -ForegroundColor Red
$failed++
}
}
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Restore Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success : $success"
Write-Host " Skipped : $skipped"
Write-Host " Failed : $failed"
}
#endregion
+341
View File
@@ -0,0 +1,341 @@
baseline:
name: Generated-ConditionalAccess-Baseline
conflictResolution: Skip
whatIf: false
tenantConfig:
conditionalAccess:
reportOnly: false
breakGlassGroup: CQRE-BreakGlass
policies:
- name: CQRE-CA0901-AllUsers-AllApps-BlockLegacyAuth
description: Block all legacy authentication protocols
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
clientAppTypes:
- exchangeActiveSync
- other
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA1901-AllUsers-SecurityInfo-RequireTrustedLocation
description: Require trusted location or managed device to register security
info
state: enabled
conditions:
applications:
includeUserActions:
- urn:user:registersecurityinfo
users:
includeUsers:
- All
grantControls:
builtInControls:
- compliantDevice
- domainJoinedDevice
operator: OR
- name: CQRE-CA0902-AllUsers-AllApps-BlockUnsupportedPlatforms
description: Block sign-ins from unknown or unsupported device platforms
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
platforms:
includePlatforms:
- all
excludePlatforms:
- android
- iOS
- windows
- macOS
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA0903-AllUsers-AllApps-BlockDeviceCodeFlow
description: Block device-code authentication flow
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
authenticationFlows:
deviceCodeFlow:
isEnabled: true
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA1902-AllUsers-AllApps-RequireMFAUntrusted
description: Require MFA only from untrusted locations
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
locations:
includeLocations:
- All
excludeLocations:
- AllTrusted
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA1903-AllUsers-AllApps-RequireCompliantDevice
description: Require compliant or hybrid-joined device for all users
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
grantControls:
builtInControls:
- compliantDevice
- domainJoinedDevice
operator: OR
- name: CQRE-CA1904-AllUsers-AllApps-BlockUntrustedLocations
description: Block sign-ins from untrusted locations
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
locations:
includeLocations:
- All
excludeLocations:
- AllTrusted
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA0904-AllUsers-AllApps-RequireMFAForRiskySignIns
description: Require MFA for medium/high risk sign-ins
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
signInRiskLevels:
- medium
- high
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA0905-AllUsers-AllApps-ForcePasswordChangeHighRiskUsers
description: Force password change for high-risk users
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
userRiskLevels:
- high
grantControls:
builtInControls:
- passwordChange
operator: OR
- name: CQRE-CA0906-AllUsers-AllApps-BlockInsiderRisk
description: Block sessions flagged by Purview Insider Risk
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
insiderRiskLevels:
- elevated
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA2901-Admins-AllApps-RequireCompliantDevice
description: Administrators must use compliant or hybrid-joined devices
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeRoles: &id001
- Global Administrator
- Privileged Role Administrator
- Security Administrator
- Exchange Administrator
- SharePoint Administrator
- Conditional Access Administrator
- Application Administrator
- Cloud Application Administrator
- User Administrator
- Helpdesk Administrator
- Billing Administrator
- Authentication Administrator
- Password Administrator
grantControls:
builtInControls:
- compliantDevice
- domainJoinedDevice
operator: OR
- name: CQRE-CA2902-Admins-AllApps-BlockUntrustedLocations
description: Administrators can only sign in from trusted locations
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeRoles: *id001
locations:
includeLocations:
- All
excludeLocations:
- AllTrusted
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA2903-Admins-AllApps-NoPersistentSession
description: No persistent browser sessions for admins; re-auth every 12h
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeRoles: *id001
grantControls:
builtInControls:
- mfa
operator: OR
sessionControls:
signInFrequency:
value: 12
type: hours
isEnabled: true
persistentBrowser:
mode: never
isEnabled: true
- name: CQRE-CA3901-Guests-AllApps-RequireMFA
description: Require MFA for guest and external users
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeGuestsOrExternalUsers:
guestTypes:
- internalGuest
- b2bCollaborationGuest
- b2bCollaborationMember
- b2bDirectConnectUser
externalTenants:
membershipKind: all
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA3902-Guests-AllApps-RequireTermsOfUse
description: Require guests to accept terms of use
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeGuestsOrExternalUsers:
guestTypes:
- internalGuest
- b2bCollaborationGuest
- b2bCollaborationMember
- b2bDirectConnectUser
externalTenants:
membershipKind: all
grantControls:
builtInControls:
- termsOfUse
operator: OR
- name: CQRE-CA4901-AllUsers-O365-AppEnforcedRestrictions
description: Enforce application restrictions for Office 365
state: enabled
conditions:
applications:
includeApplications:
- Office365
users:
includeUsers:
- All
grantControls:
builtInControls:
- mfa
operator: OR
sessionControls:
applicationEnforcedRestrictions:
isEnabled: true
- name: CQRE-CA4902-AllUsers-AzureMgmt-RequireMFA
description: Require MFA for Azure management portal
state: enabled
conditions:
applications:
includeApplications:
- 797f4846-ba00-4fd7-ba43-dac1f8f63013
users:
includeUsers:
- All
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA4903-AllUsers-AdminPortals-RequireMFA
description: Require MFA for Microsoft admin portals
state: enabled
conditions:
applications:
includeApplications:
- 797f4846-ba00-4fd7-ba43-dac1f8f63013
- c44b4083-3bb0-49c1-b47d-974e53cbdf3c
- 1b730954-1685-4b74-9bfd-dac224a7b894
- 00000003-0000-0ff1-ce00-000000000000
- 00000003-0000-0000-c000-000000000000
- de8bc8b5-d9f9-48b1-a8ad-b748da725064
- 00000002-0000-0ff1-ce00-000000000000
- 66a88757-258c-4c72-893c-3e8bed4d6899
users:
includeUsers:
- All
grantControls:
builtInControls:
- mfa
operator: OR
+446
View File
@@ -0,0 +1,446 @@
#requires -Version 5.1
<#
.SYNOPSIS
Headless bulk app assignment tool for Intune — cross-platform TUI version.
.DESCRIPTION
Assign multiple Intune apps to multiple Azure AD groups (or All Users / All Devices)
in a single operation. Runs on macOS, Linux, and Windows.
Uses fzf for multi-select when available; falls back to numbered menus.
Integrates with the IntuneManagement headless auth stack.
.EXAMPLE
./Scripts/Bulk-AppAssignment.ps1 -TenantId "contoso.onmicrosoft.com" -AppId "..." -Secret "..."
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Read-YesNo
{
param(
[string]$Prompt,
[bool]$Default = $false
)
$defaultChar = if($Default) { "Y" } else { "N" }
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
return $response -match "^\s*y"
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
#region Load Apps
Write-Host "`nLoading applications from Intune..." -ForegroundColor Cyan
$appUrl = "/deviceAppManagement/mobileApps?`$select=id,displayName,publisher&`$filter=(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20'lineOfBusiness'%20or%20isAssigned%20eq%20true)&`$orderby=displayName"
$appsResponse = Invoke-GraphRequest $appUrl -AllPages
$apps = $appsResponse.value | Where-Object { $_.displayName } | Sort-Object displayName
Write-Host "Found $($apps.Count) applications." -ForegroundColor Green
$appFilter = Read-Host "`nFilter apps by name (optional, press Enter to skip)"
if(-not [string]::IsNullOrWhiteSpace($appFilter))
{
$apps = $apps | Where-Object { $_.displayName -like "*$appFilter*" }
Write-Host "Filtered to $($apps.Count) applications." -ForegroundColor Green
}
if($apps.Count -eq 0)
{
Write-Host "No apps found. Exiting." -ForegroundColor Yellow
exit 0
}
$appDisplayNames = $apps | ForEach-Object { "$($_.displayName) [$($_.id)]" }
$selectedAppDisplays = Select-MenuItem -Items $appDisplayNames -Header "Select apps to assign (multi-select)" -Multi
if(-not $selectedAppDisplays)
{
Write-Host "No apps selected. Exiting." -ForegroundColor Yellow
exit 0
}
$selectedApps = @()
foreach($disp in $selectedAppDisplays)
{
$id = $disp -replace '.*\[(.*?)\]$', '$1'
$app = $apps | Where-Object { $_.id -eq $id } | Select-Object -First 1
if($app) { $selectedApps += $app }
}
Write-Host "Selected $($selectedApps.Count) apps." -ForegroundColor Green
#endregion
#region Load Groups
Write-Host "`nLoading Azure AD groups..." -ForegroundColor Cyan
$groupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
$groups = $groupsResponse.value | Where-Object { $_.displayName } | Sort-Object displayName
Write-Host "Found $($groups.Count) groups." -ForegroundColor Green
$groupDisplayNames = $groups | ForEach-Object { "$($_.displayName) [$($_.id)]" }
$selectedGroupDisplays = Select-MenuItem -Items $groupDisplayNames -Header "Select target groups (multi-select)" -Multi
$selectedGroups = @()
if($selectedGroupDisplays)
{
foreach($disp in $selectedGroupDisplays)
{
$id = $disp -replace '.*\[(.*?)\]$', '$1'
$grp = $groups | Where-Object { $_.id -eq $id } | Select-Object -First 1
if($grp) { $selectedGroups += $grp }
}
}
#endregion
#region Special Targets & Intent
$intent = Select-MenuItem -Items @("required","available","uninstall") -Header "Select assignment intent"
if(-not $intent) { $intent = "required" }
$allUsers = Read-YesNo -Prompt "Target All Users?" -Default $false
$allDevices = $false
if($intent -ne "available")
{
$allDevices = Read-YesNo -Prompt "Target All Devices?" -Default $false
}
else
{
Write-Host "All Devices is not supported with Available intent." -ForegroundColor DarkGray
}
if(($selectedGroups.Count -eq 0) -and -not $allUsers -and -not $allDevices)
{
Write-Host "No targets selected. Exiting." -ForegroundColor Yellow
exit 0
}
#endregion
#region Review
Clear-Host
Write-Host "Review bulk assignment:" -ForegroundColor Green
Write-Host " Intent : $intent"
Write-Host " Apps : $($selectedApps.Count)"
foreach($a in $selectedApps) { Write-Host " - $($a.displayName)" }
Write-Host " Groups : $($selectedGroups.Count)"
foreach($g in $selectedGroups) { Write-Host " - $($g.displayName)" }
Write-Host " All Users : $allUsers"
Write-Host " All Devices : $allDevices"
$confirm = Read-Host "`nProceed? [Y/n]"
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
{
Write-Host "Cancelled." -ForegroundColor Yellow
exit 0
}
#endregion
#region Execute Assignments
$success = 0
$skipped = 0
$failed = 0
foreach($app in $selectedApps)
{
Write-Host "`nProcessing: $($app.displayName)" -ForegroundColor Cyan
# Load existing assignments
try
{
$existing = Invoke-GraphRequest "/deviceAppManagement/mobileApps/$($app.id)/assignments"
$existingTargets = $existing.value
}
catch
{
Write-Host " ERROR: Could not load existing assignments for $($app.displayName)" -ForegroundColor Red
$failed++
continue
}
# Helper to check if assignment already exists
function Test-AssignmentExists
{
param($targetType, $groupId, $intentValue)
foreach($ea in $existingTargets)
{
if($ea.intent -ne $intentValue) { continue }
$t = $ea.target
if($targetType -eq "group" -and $t."@odata.type" -eq "#microsoft.graph.groupAssignmentTarget" -and $t.groupId -eq $groupId)
{
return $true
}
if($targetType -eq "allUsers" -and $t."@odata.type" -eq "#microsoft.graph.allLicensedUsersAssignmentTarget")
{
return $true
}
if($targetType -eq "allDevices" -and $t."@odata.type" -eq "#microsoft.graph.allDevicesAssignmentTarget")
{
return $true
}
}
return $false
}
# Build payloads
$payloads = @()
foreach($grp in $selectedGroups)
{
if(Test-AssignmentExists -targetType "group" -groupId $grp.id -intentValue $intent)
{
Write-Host " SKIP: $($grp.displayName) (already assigned)" -ForegroundColor DarkYellow
$skipped++
continue
}
$payloads += @{
"@odata.type" = "#microsoft.graph.mobileAppAssignment"
intent = $intent
target = @{
"@odata.type" = "#microsoft.graph.groupAssignmentTarget"
groupId = $grp.id
}
}
Write-Host " QUEUE: Group -> $($grp.displayName)" -ForegroundColor Gray
}
if($allUsers)
{
if(Test-AssignmentExists -targetType "allUsers" -intentValue $intent)
{
Write-Host " SKIP: All Users (already assigned)" -ForegroundColor DarkYellow
$skipped++
}
else
{
$payloads += @{
"@odata.type" = "#microsoft.graph.mobileAppAssignment"
intent = $intent
target = @{
"@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget"
}
}
Write-Host " QUEUE: All Users" -ForegroundColor Gray
}
}
if($allDevices)
{
if(Test-AssignmentExists -targetType "allDevices" -intentValue $intent)
{
Write-Host " SKIP: All Devices (already assigned)" -ForegroundColor DarkYellow
$skipped++
}
else
{
$payloads += @{
"@odata.type" = "#microsoft.graph.mobileAppAssignment"
intent = $intent
target = @{
"@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget"
}
}
Write-Host " QUEUE: All Devices" -ForegroundColor Gray
}
}
# Post assignments
foreach($payload in $payloads)
{
try
{
$body = $payload | ConvertTo-Json -Depth 10 -Compress
$null = Invoke-GraphRequest "/deviceAppManagement/mobileApps/$($app.id)/assignments" -HttpMethod POST -Content $body
Write-Host " OK: Assigned target" -ForegroundColor Green
$success++
}
catch
{
Write-Host " ERROR: Failed to assign target. $($_.Exception.Message)" -ForegroundColor Red
$failed++
}
}
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Bulk Assignment Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success : $success"
Write-Host " Skipped : $skipped"
Write-Host " Failed : $failed"
#endregion
+732
View File
@@ -0,0 +1,732 @@
#requires -Version 5.1
<#
.SYNOPSIS
Cross-platform bulk assignment manager for Intune policies and apps.
.DESCRIPTION
Add or remove assignments across multiple Intune object types
(Device Configuration, Compliance, Settings Catalog, Apps, Scripts, etc.)
in a single operation. Uses fzf when available; falls back to numbered menus.
Integrates with the IntuneManagement headless auth stack.
.EXAMPLE
./Scripts/Bulk-AssignmentManager.ps1 -TenantId "contoso.onmicrosoft.com" -AppId "..." -Secret "..."
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Read-YesNo
{
param(
[string]$Prompt,
[bool]$Default = $false
)
$defaultChar = if($Default) { "Y" } else { "N" }
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
return $response -match "^\s*y"
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
#region Object type registry (assignable types)
$assignableTypes = @(
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; AssignmentODataType = "#microsoft.graph.mobileAppAssignment"; HasIntent = $true; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementConfigurationPolicyAssignment"; HasIntent = $false; NameProp = "name" },
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.groupPolicyConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.targetedManagedAppPolicyAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.managedDeviceMobileAppConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceHealthScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsAutopilotDeploymentProfileAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.termsAndConditionsAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.policySetAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsUpdateForBusinessConfigurationAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsFeatureUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsQualityUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName" }
)
#endregion
#region Action selection
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Intune Bulk Assignment Manager" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
$action = Select-MenuItem -Items @("Add assignments","Remove assignments") -Header "Select action"
if(-not $action) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
#endregion
#region Select object type
$typeTitles = $assignableTypes | ForEach-Object { $_.Title }
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
$objectType = $assignableTypes | Where-Object { $_.Title -eq $selectedTypeTitle } | Select-Object -First 1
#endregion
#region Load objects
Write-Host "`nLoading $($objectType.Title) objects..." -ForegroundColor Cyan
$api = "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
$objectsResponse = Invoke-GraphRequest $api -AllPages
$objects = $objectsResponse.value | Where-Object { $_ } | Sort-Object $objectType.NameProp
Write-Host "Found $($objects.Count) objects." -ForegroundColor Green
$filter = Read-Host "`nFilter by name (optional, press Enter to skip)"
if(-not [string]::IsNullOrWhiteSpace($filter))
{
$objects = $objects | Where-Object { $_."$($objectType.NameProp)" -like "*$filter*" }
Write-Host "Filtered to $($objects.Count) objects." -ForegroundColor Green
}
if($objects.Count -eq 0)
{
Write-Host "No objects found. Exiting." -ForegroundColor Yellow
exit 0
}
$objectDisplays = $objects | ForEach-Object { "$($_."$($objectType.NameProp)") [$($_.id)]" }
$selectedDisplays = Select-MenuItem -Items $objectDisplays -Header "Select objects (multi-select)" -Multi
if(-not $selectedDisplays)
{
Write-Host "No objects selected. Exiting." -ForegroundColor Yellow
exit 0
}
$selectedObjects = @()
foreach($disp in $selectedDisplays)
{
$id = $disp -replace '.*\[(.*?)\]$', '$1'
$obj = $objects | Where-Object { $_.id -eq $id } | Select-Object -First 1
if($obj) { $selectedObjects += $obj }
}
Write-Host "Selected $($selectedObjects.Count) objects." -ForegroundColor Green
#endregion
#region Load groups & filters
Write-Host "`nLoading Azure AD groups..." -ForegroundColor Cyan
$groupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
$groups = $groupsResponse.value | Where-Object { $_.displayName } | Sort-Object displayName
Write-Host "Found $($groups.Count) groups." -ForegroundColor Green
Write-Host "`nLoading assignment filters..." -ForegroundColor Cyan
$filtersResponse = Invoke-GraphRequest "/deviceManagement/assignmentFilters?`$select=id,displayName&`$orderby=displayName"
$assignmentFilters = $filtersResponse.value | Where-Object { $_.displayName } | Sort-Object displayName
Write-Host "Found $($assignmentFilters.Count) filters." -ForegroundColor Green
#endregion
#region Add assignments flow
if($action -eq "Add assignments")
{
$groupDisplays = $groups | ForEach-Object { "$($_.displayName) [$($_.id)]" }
$selectedGroupDisplays = Select-MenuItem -Items $groupDisplays -Header "Select target groups (multi-select)" -Multi
$selectedGroups = @()
if($selectedGroupDisplays)
{
foreach($disp in $selectedGroupDisplays)
{
$id = $disp -replace '.*\[(.*?)\]$', '$1'
$grp = $groups | Where-Object { $_.id -eq $id } | Select-Object -First 1
if($grp) { $selectedGroups += $grp }
}
}
$allUsers = Read-YesNo -Prompt "Target All Users?" -Default $false
$allDevices = Read-YesNo -Prompt "Target All Devices?" -Default $false
if(($selectedGroups.Count -eq 0) -and -not $allUsers -and -not $allDevices)
{
Write-Host "No targets selected. Exiting." -ForegroundColor Yellow
exit 0
}
$intent = $null
if($objectType.HasIntent)
{
$intent = Select-MenuItem -Items @("required","available","uninstall") -Header "Select assignment intent"
if(-not $intent) { $intent = "required" }
if($intent -eq "available")
{
Write-Host "Note: All Devices cannot be targeted with Available intent." -ForegroundColor DarkGray
$allDevices = $false
}
}
$includeExclude = "include"
if($selectedGroups.Count -gt 0)
{
$includeExclude = Select-MenuItem -Items @("include","exclude") -Header "Group target mode"
if(-not $includeExclude) { $includeExclude = "include" }
}
$filterDisplay = "(none)"
if($assignmentFilters.Count -gt 0)
{
$filterDisplays = @("(none)") + ($assignmentFilters | ForEach-Object { "$($_.displayName) [$($_.id)]" })
$filterSelection = Select-MenuItem -Items $filterDisplays -Header "Select assignment filter (optional)"
if($filterSelection -and $filterSelection -ne "(none)")
{
$filterId = $filterSelection -replace '.*\[(.*?)\]$', '$1'
$filterObj = $assignmentFilters | Where-Object { $_.id -eq $filterId } | Select-Object -First 1
if($filterObj) { $filterDisplay = $filterObj.displayName }
}
}
# Review
Clear-Host
Write-Host "Review add-assignment operation:" -ForegroundColor Green
Write-Host " Object Type : $($objectType.Title)"
Write-Host " Objects : $($selectedObjects.Count)"
Write-Host " Groups : $($selectedGroups.Count)"
Write-Host " All Users : $allUsers"
Write-Host " All Devices : $allDevices"
if($intent) { Write-Host " Intent : $intent" }
Write-Host " Mode : $includeExclude"
Write-Host " Filter : $filterDisplay"
$confirm = Read-Host "`nProceed? [Y/n]"
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
{
Write-Host "Cancelled." -ForegroundColor Yellow
exit 0
}
# Execute
$success = 0
$skipped = 0
$failed = 0
foreach($obj in $selectedObjects)
{
Write-Host "`nProcessing: $($obj."$($objectType.NameProp)")" -ForegroundColor Cyan
try
{
$existing = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
$existingTargets = $existing.value
}
catch
{
Write-Host " ERROR: Could not load existing assignments" -ForegroundColor Red
$failed++
continue
}
function Test-AssignmentExists
{
param($targetType, $groupId)
foreach($ea in $existingTargets)
{
$t = $ea.target
if($targetType -eq "group" -and $t."@odata.type" -eq "#microsoft.graph.groupAssignmentTarget" -and $t.groupId -eq $groupId) { return $true }
if($targetType -eq "allUsers" -and $t."@odata.type" -eq "#microsoft.graph.allLicensedUsersAssignmentTarget") { return $true }
if($targetType -eq "allDevices" -and $t."@odata.type" -eq "#microsoft.graph.allDevicesAssignmentTarget") { return $true }
if($targetType -eq "excludeGroup" -and $t."@odata.type" -eq "#microsoft.graph.exclusionGroupAssignmentTarget" -and $t.groupId -eq $groupId) { return $true }
}
return $false
}
$payloads = @()
foreach($grp in $selectedGroups)
{
$targetTypeName = if($includeExclude -eq "exclude") { "excludeGroup" } else { "group" }
$odataType = if($includeExclude -eq "exclude") { "#microsoft.graph.exclusionGroupAssignmentTarget" } else { "#microsoft.graph.groupAssignmentTarget" }
if(Test-AssignmentExists -targetType $targetTypeName -groupId $grp.id)
{
Write-Host " SKIP: $($grp.displayName) ($includeExclude) already assigned" -ForegroundColor DarkYellow
$skipped++
continue
}
$targetPayload = @{
"@odata.type" = $odataType
groupId = $grp.id
}
if($filterObj)
{
$targetPayload["deviceAndAppManagementAssignmentFilterId"] = $filterObj.id
$targetPayload["deviceAndAppManagementAssignmentFilterType"] = "include"
}
$assignmentPayload = @{
"@odata.type" = $objectType.AssignmentODataType
target = $targetPayload
}
if($objectType.HasIntent -and $intent)
{
$assignmentPayload.intent = $intent
}
$payloads += $assignmentPayload
Write-Host " QUEUE: Group -> $($grp.displayName) ($includeExclude)" -ForegroundColor Gray
}
if($allUsers)
{
if(Test-AssignmentExists -targetType "allUsers")
{
Write-Host " SKIP: All Users already assigned" -ForegroundColor DarkYellow
$skipped++
}
else
{
$targetPayload = @{
"@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget"
}
if($filterObj)
{
$targetPayload["deviceAndAppManagementAssignmentFilterId"] = $filterObj.id
$targetPayload["deviceAndAppManagementAssignmentFilterType"] = "include"
}
$assignmentPayload = @{
"@odata.type" = $objectType.AssignmentODataType
target = $targetPayload
}
if($objectType.HasIntent -and $intent)
{
$assignmentPayload.intent = $intent
}
$payloads += $assignmentPayload
Write-Host " QUEUE: All Users" -ForegroundColor Gray
}
}
if($allDevices)
{
if(Test-AssignmentExists -targetType "allDevices")
{
Write-Host " SKIP: All Devices already assigned" -ForegroundColor DarkYellow
$skipped++
}
else
{
$targetPayload = @{
"@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget"
}
if($filterObj)
{
$targetPayload["deviceAndAppManagementAssignmentFilterId"] = $filterObj.id
$targetPayload["deviceAndAppManagementAssignmentFilterType"] = "include"
}
$assignmentPayload = @{
"@odata.type" = $objectType.AssignmentODataType
target = $targetPayload
}
if($objectType.HasIntent -and $intent)
{
$assignmentPayload.intent = $intent
}
$payloads += $assignmentPayload
Write-Host " QUEUE: All Devices" -ForegroundColor Gray
}
}
if($payloads.Count -eq 0)
{
continue
}
# Merge existing + new assignments and POST to /assign (the standard Intune bulk endpoint)
try
{
$allAssignments = @()
# Clean existing assignments (remove id/source, preserve structure)
foreach($ea in $existingTargets)
{
$clean = $ea | ConvertTo-Json -Depth 50 | ConvertFrom-Json
if($clean.PSObject.Properties["id"]) { $clean.PSObject.Properties.Remove("id") }
if($clean.PSObject.Properties["source"]) { $clean.PSObject.Properties.Remove("source") }
if(-not $clean."@odata.type")
{
$clean | Add-Member -NotePropertyName "@odata.type" -NotePropertyValue $objectType.AssignmentODataType -Force
}
$allAssignments += $clean
}
foreach($p in $payloads)
{
$allAssignments += $p
}
$assignPayload = @{
$objectType.AssignmentsType = $allAssignments
} | ConvertTo-Json -Depth 50 -Compress
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assign" -HttpMethod POST -Content $assignPayload
Write-Host " OK: Assigned $($payloads.Count) new target(s)" -ForegroundColor Green
$success += $payloads.Count
}
catch
{
Write-Host " ERROR: Failed to assign. $($_.Exception.Message)" -ForegroundColor Red
$failed += $payloads.Count
}
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Add Assignments Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success : $success"
Write-Host " Skipped : $skipped"
Write-Host " Failed : $failed"
}
#endregion
#region Remove assignments flow
elseif($action -eq "Remove assignments")
{
# Gather all existing assignments across selected objects
Write-Host "`nLoading existing assignments..." -ForegroundColor Cyan
$allAssignments = @()
foreach($obj in $selectedObjects)
{
try
{
$existing = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
foreach($ass in $existing.value)
{
$targetDesc = "Unknown"
$targetType = $ass.target."@odata.type"
if($targetType -eq "#microsoft.graph.groupAssignmentTarget")
{
$grp = $groups | Where-Object { $_.id -eq $ass.target.groupId } | Select-Object -First 1
$targetDesc = "Include: $(if($grp){$grp.displayName}else{$ass.target.groupId})"
}
elseif($targetType -eq "#microsoft.graph.exclusionGroupAssignmentTarget")
{
$grp = $groups | Where-Object { $_.id -eq $ass.target.groupId } | Select-Object -First 1
$targetDesc = "Exclude: $(if($grp){$grp.displayName}else{$ass.target.groupId})"
}
elseif($targetType -eq "#microsoft.graph.allLicensedUsersAssignmentTarget")
{
$targetDesc = "All Users"
}
elseif($targetType -eq "#microsoft.graph.allDevicesAssignmentTarget")
{
$targetDesc = "All Devices"
}
$allAssignments += [PSCustomObject]@{
ObjectId = $obj.id
ObjectName = $obj."$($objectType.NameProp)"
AssignmentId = $ass.id
TargetDesc = $targetDesc
TargetType = $targetType
GroupId = $ass.target.groupId
}
}
}
catch
{
Write-Host " WARNING: Could not load assignments for $($obj."$($objectType.NameProp)")" -ForegroundColor DarkYellow
}
}
if($allAssignments.Count -eq 0)
{
Write-Host "No assignments found to remove. Exiting." -ForegroundColor Yellow
exit 0
}
# Deduplicate by target description for selection
$uniqueTargets = $allAssignments | Select-Object -Property TargetDesc, TargetType, GroupId -Unique
$targetDisplays = $uniqueTargets | ForEach-Object { $_.TargetDesc }
$selectedTargetDisplays = Select-MenuItem -Items $targetDisplays -Header "Select assignments to remove (multi-select)" -Multi
if(-not $selectedTargetDisplays)
{
Write-Host "No targets selected. Exiting." -ForegroundColor Yellow
exit 0
}
# Review
Clear-Host
Write-Host "Review remove-assignment operation:" -ForegroundColor Green
Write-Host " Object Type : $($objectType.Title)"
Write-Host " Objects : $($selectedObjects.Count)"
Write-Host " Targets to remove:" -ForegroundColor Yellow
foreach($td in $selectedTargetDisplays)
{
$count = ($allAssignments | Where-Object { $_.TargetDesc -eq $td } | Measure-Object).Count
Write-Host " - $td ($count occurrence$(if($count -ne 1){'s'}))"
}
$confirm = Read-Host "`nProceed? [Y/n]"
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
{
Write-Host "Cancelled." -ForegroundColor Yellow
exit 0
}
# Helper: compute TargetDesc for an assignment
function Get-AssignmentTargetDesc
{
param($Ass)
$tt = $Ass.target."@odata.type"
switch($tt)
{
"#microsoft.graph.groupAssignmentTarget"
{
$grp = $groups | Where-Object { $_.id -eq $Ass.target.groupId } | Select-Object -First 1
return "Include: $(if($grp){$grp.displayName}else{$Ass.target.groupId})"
}
"#microsoft.graph.exclusionGroupAssignmentTarget"
{
$grp = $groups | Where-Object { $_.id -eq $Ass.target.groupId } | Select-Object -First 1
return "Exclude: $(if($grp){$grp.displayName}else{$Ass.target.groupId})"
}
"#microsoft.graph.allLicensedUsersAssignmentTarget" { return "All Users" }
"#microsoft.graph.allDevicesAssignmentTarget" { return "All Devices" }
default { return "Unknown" }
}
}
# Execute
$success = 0
$failed = 0
foreach($obj in $selectedObjects)
{
$objAssignments = $allAssignments | Where-Object { $_.ObjectId -eq $obj.id -and $_.TargetDesc -in $selectedTargetDisplays }
if($objAssignments.Count -eq 0) { continue }
Write-Host "`nProcessing: $($obj."$($objectType.NameProp)")" -ForegroundColor Cyan
try
{
$existing = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
$remaining = @()
foreach($ea in $existing.value)
{
$desc = Get-AssignmentTargetDesc -Ass $ea
if($desc -in $selectedTargetDisplays)
{
continue
}
# Sanitize for re-post
$clean = $ea | ConvertTo-Json -Depth 50 | ConvertFrom-Json
if($clean.PSObject.Properties["id"]) { $clean.PSObject.Properties.Remove("id") }
if($clean.PSObject.Properties["source"]) { $clean.PSObject.Properties.Remove("source") }
if(-not $clean."@odata.type")
{
$clean | Add-Member -NotePropertyName "@odata.type" -NotePropertyValue $objectType.AssignmentODataType -Force
}
$remaining += $clean
}
$assignPayload = @{
$objectType.AssignmentsType = $remaining
} | ConvertTo-Json -Depth 50 -Compress
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assign" -HttpMethod POST -Content $assignPayload
foreach($ass in $objAssignments)
{
Write-Host " OK: Removed $($ass.TargetDesc)" -ForegroundColor Green
$success++
}
}
catch
{
foreach($ass in $objAssignments)
{
Write-Host " ERROR: Failed to remove $($ass.TargetDesc). $($_.Exception.Message)" -ForegroundColor Red
$failed++
}
}
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Remove Assignments Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success : $success"
Write-Host " Failed : $failed"
}
#endregion
+354
View File
@@ -0,0 +1,354 @@
#requires -Version 5.1
<#
.SYNOPSIS
Bulk delete Intune policies/apps by object type and name filter.
.DESCRIPTION
Select an object type, optionally filter by name, multi-select objects,
and delete them from the tenant. Requires typing DELETE to confirm since
this is irreversible. Supports -WhatIf dry-run.
.EXAMPLE
./Scripts/Bulk-DeletePolicies.ps1 -TenantId "contoso.onmicrosoft.com" -WhatIf
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile,
[switch]$WhatIf
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
#region Object type registry (deletable types)
$deletableTypes = @(
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; NameProp = "name" },
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; NameProp = "displayName" }
)
#endregion
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Intune Bulk Delete Tool" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
#region Select object type
$typeTitles = $deletableTypes | ForEach-Object { $_.Title }
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
$objectType = $deletableTypes | Where-Object { $_.Title -eq $selectedTypeTitle } | Select-Object -First 1
#endregion
#region Load objects
Write-Host "`nLoading $($objectType.Title) objects..." -ForegroundColor Cyan
$api = "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
$objectsResponse = Invoke-GraphRequest $api -AllPages
$objects = $objectsResponse.value | Where-Object { $_ } | Sort-Object $objectType.NameProp
Write-Host "Found $($objects.Count) objects." -ForegroundColor Green
$filter = Read-Host "`nFilter by current name (optional, press Enter to skip)"
if(-not [string]::IsNullOrWhiteSpace($filter))
{
$objects = $objects | Where-Object { $_."$($objectType.NameProp)" -like "*$filter*" }
Write-Host "Filtered to $($objects.Count) objects." -ForegroundColor Green
}
if($objects.Count -eq 0)
{
Write-Host "No objects found. Exiting." -ForegroundColor Yellow
exit 0
}
$objectDisplays = $objects | ForEach-Object { "$($_."$($objectType.NameProp)") [$($_.id)]" }
$selectedDisplays = Select-MenuItem -Items $objectDisplays -Header "Select objects to DELETE (multi-select)" -Multi
if(-not $selectedDisplays)
{
Write-Host "No objects selected. Exiting." -ForegroundColor Yellow
exit 0
}
$selectedObjects = @()
foreach($disp in $selectedDisplays)
{
$id = $disp -replace '.*\[(.*?)\]$', '$1'
$obj = $objects | Where-Object { $_.id -eq $id } | Select-Object -First 1
if($obj) { $selectedObjects += $obj }
}
#endregion
#region Confirm
Write-Host "`nThe following $($selectedObjects.Count) $($objectType.Title) object(s) will be PERMANENTLY DELETED:" -ForegroundColor Red
foreach($obj in $selectedObjects)
{
Write-Host " - $($obj."$($objectType.NameProp)") [$($obj.id)]" -ForegroundColor DarkGray
}
if(-not $WhatIf)
{
$typed = Read-Host "`nThis cannot be undone. Type DELETE to confirm"
if($typed -cne "DELETE")
{
Write-Host "Confirmation text did not match. Cancelled." -ForegroundColor Yellow
exit 0
}
}
#endregion
#region Execute
$success = 0
$failed = 0
foreach($obj in $selectedObjects)
{
$name = $obj."$($objectType.NameProp)"
try
{
if($WhatIf)
{
Write-Host " WHATIF: Would delete $name [$($obj.id)]" -ForegroundColor Magenta
$success++
}
else
{
$maxRetries = 3
$retryDelay = 2
$deleted = $false
for($r = 1; $r -le $maxRetries; $r++)
{
try
{
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)" -HttpMethod DELETE
Write-Host " OK: Deleted '$name'" -ForegroundColor Green
$success++
$deleted = $true
break
}
catch
{
$statusCode = $_.Exception.Response.StatusCode
if($r -lt $maxRetries -and ($statusCode -ge 500 -or $statusCode -eq 429))
{
Write-Host " Retry $r/$maxRetries after $retryDelay`s (HTTP $statusCode)..." -ForegroundColor DarkYellow
Start-Sleep -Seconds $retryDelay
}
else
{
throw
}
}
}
if(-not $deleted) { throw "Delete failed after $maxRetries attempts." }
}
}
catch
{
Write-Host " ERROR: Failed to delete '$name'. $($_.Exception.Message)" -ForegroundColor Red
$failed++
}
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Bulk Delete Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success : $success"
Write-Host " Failed : $failed"
#endregion
+411
View File
@@ -0,0 +1,411 @@
#requires -Version 5.1
<#
.SYNOPSIS
Bulk device operations for Intune with enterprise-grade safeguards.
.DESCRIPTION
Retire, wipe, delete, or sync devices in bulk with filtering, dry-run mode,
and exclusions for hybrid-joined devices. Uses fzf when available.
.EXAMPLE
./Scripts/Bulk-DeviceOperations.ps1 -TenantId "contoso.onmicrosoft.com" -WhatIf
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile,
[switch]$WhatIf
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Read-YesNo
{
param(
[string]$Prompt,
[bool]$Default = $false
)
$defaultChar = if($Default) { "Y" } else { "N" }
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
return $response -match "^\s*y"
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Intune Bulk Device Operations" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if($WhatIf)
{
Write-Host "`n*** DRY-RUN MODE ENABLED ***" -ForegroundColor Magenta
Write-Host "No destructive actions will be performed." -ForegroundColor Magenta
}
#region Action selection
$action = Select-MenuItem -Items @("Delete","Retire","Wipe (Factory Reset)","Remote Lock","Sync") -Header "Select device operation"
if(-not $action) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
$actionValue = switch($action)
{
"Delete" { "delete" }
"Retire" { "retire" }
"Wipe (Factory Reset)" { "wipe" }
"Remote Lock" { "remoteLock" }
"Sync" { "syncDevice" }
}
#endregion
#region Load devices with filtering
Write-Host "`nLoading managed devices..." -ForegroundColor Cyan
$deviceUrl = "/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,complianceState,lastSyncDateTime,azureADDeviceId,azureADRegistered,isEncrypted,userPrincipalName,ownerType,managementState&`$orderby=deviceName"
$devicesResponse = Invoke-GraphRequest $deviceUrl
$devices = $devicesResponse.value | Where-Object { $_.deviceName } | Sort-Object deviceName
Write-Host "Found $($devices.Count) devices." -ForegroundColor Green
# Filters
Write-Host "`n--- Apply Filters ---" -ForegroundColor Cyan
$osFilter = Read-Host "Filter by OS (Windows, iOS, macOS, Android — or press Enter for all)"
if(-not [string]::IsNullOrWhiteSpace($osFilter))
{
$devices = $devices | Where-Object { $_.operatingSystem -like "*$osFilter*" }
}
$complianceFilter = Select-MenuItem -Items @("(all)","compliant","noncompliant","unknown","notApplicable","remediated","error","conflict") -Header "Filter by compliance state"
if($complianceFilter -and $complianceFilter -ne "(all)")
{
$devices = $devices | Where-Object { $_.complianceState -eq $complianceFilter }
}
$daysInactive = Read-Host "Only show devices inactive for more than N days (press Enter to skip)"
if(-not [string]::IsNullOrWhiteSpace($daysInactive) -and $daysInactive -match "^\d+$")
{
$cutoff = (Get-Date).AddDays(-[int]$daysInactive)
$devices = $devices | Where-Object { [datetime]$_.lastSyncDateTime -lt $cutoff }
}
$nameFilter = Read-Host "Filter by device name (partial match, press Enter to skip)"
if(-not [string]::IsNullOrWhiteSpace($nameFilter))
{
$devices = $devices | Where-Object { $_.deviceName -like "*$nameFilter*" }
}
Write-Host "`nFiltered to $($devices.Count) devices." -ForegroundColor Green
if($devices.Count -eq 0)
{
Write-Host "No devices match filters. Exiting." -ForegroundColor Yellow
exit 0
}
$deviceDisplays = $devices | ForEach-Object { "$($_.deviceName) | $($_.operatingSystem) | $($_.complianceState) | $($_.userPrincipalName) [$($_.id)]" }
$selectedDisplays = Select-MenuItem -Items $deviceDisplays -Header "Select devices (multi-select)" -Multi
if(-not $selectedDisplays)
{
Write-Host "No devices selected. Exiting." -ForegroundColor Yellow
exit 0
}
$selectedDevices = @()
foreach($disp in $selectedDisplays)
{
$id = $disp -replace '.*\[(.*?)\]$', '$1'
$dev = $devices | Where-Object { $_.id -eq $id } | Select-Object -First 1
if($dev) { $selectedDevices += $dev }
}
Write-Host "Selected $($selectedDevices.Count) devices." -ForegroundColor Green
#endregion
#region Safeguards
$excludeHybrid = Read-YesNo -Prompt "Exclude hybrid Azure AD joined devices?" -Default $true
if($excludeHybrid)
{
$preCount = $selectedDevices.Count
# We need ownerType or join type info. managedDevices doesn't always expose hybrid directly,
# but azureADRegistered + ownerType can help. We'll check azureADDeviceId against devices endpoint for joinType.
Write-Host "`nChecking device join types..." -ForegroundColor Cyan
$aadDeviceIds = $selectedDevices | Where-Object { $_.azureADDeviceId } | Select-Object -ExpandProperty azureADDeviceId -Unique
$hybridIds = @{}
foreach($aadId in $aadDeviceIds)
{
try
{
$aadDevice = Invoke-GraphRequest "/devices?`$filter=deviceId eq '$aadId'&`$select=id,displayName,joinType"
if($aadDevice.value -and $aadDevice.value[0].joinType -eq "hybridAzureADJoin")
{
$hybridIds[$aadId] = $true
}
}
catch { }
}
$selectedDevices = $selectedDevices | Where-Object { -not $hybridIds[$_.azureADDeviceId] }
$excluded = $preCount - $selectedDevices.Count
if($excluded -gt 0)
{
Write-Host "Excluded $excluded hybrid-joined device(s)." -ForegroundColor Yellow
}
}
if($selectedDevices.Count -eq 0)
{
Write-Host "No devices remaining after safeguards. Exiting." -ForegroundColor Yellow
exit 0
}
#endregion
#region Review
Clear-Host
Write-Host "Review operation:" -ForegroundColor Green
Write-Host " Action : $action"
Write-Host " Devices : $($selectedDevices.Count)"
foreach($d in $selectedDevices)
{
Write-Host " - $($d.deviceName) ($($d.operatingSystem)) | $($d.userPrincipalName)"
}
$confirmText = switch($actionValue)
{
"delete" { "PERMANENTLY DELETE" }
"wipe" { "FACTORY RESET" }
default { $action.ToUpper() }
}
$confirm = Read-Host "`nType '$confirmText' to confirm, or press Enter to cancel"
if($confirm -ne $confirmText)
{
Write-Host "Cancelled." -ForegroundColor Yellow
exit 0
}
#endregion
#region Execute
$success = 0
$failed = 0
foreach($dev in $selectedDevices)
{
Write-Host "`nProcessing: $($dev.deviceName)" -ForegroundColor Cyan -NoNewline
try
{
if($WhatIf)
{
Write-Host " [WHATIF: $actionValue]" -ForegroundColor Magenta
$success++
continue
}
if($actionValue -in @("delete","retire","remoteLock","syncDevice"))
{
$url = "/deviceManagement/managedDevices/$($dev.id)/$actionValue"
$null = Invoke-GraphRequest $url -HttpMethod POST
}
elseif($actionValue -eq "wipe")
{
# Wipe supports keepEnrollmentData / keepUserData flags
$keepEnrollment = Read-YesNo -Prompt "Keep enrollment data for $($dev.deviceName)?" -Default $false
$keepUserData = Read-YesNo -Prompt "Keep user data for $($dev.deviceName)?" -Default $false
$body = @{
keepEnrollmentData = $keepEnrollment
keepUserData = $keepUserData
macOsUnlockCode = ""
} | ConvertTo-Json -Compress
$null = Invoke-GraphRequest "/deviceManagement/managedDevices/$($dev.id)/wipe" -HttpMethod POST -Content $body
}
Write-Host " -> OK" -ForegroundColor Green
$success++
}
catch
{
Write-Host " -> ERROR: $($_.Exception.Message)" -ForegroundColor Red
$failed++
}
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Bulk Device Operations Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success : $success"
Write-Host " Failed : $failed"
#endregion
+450
View File
@@ -0,0 +1,450 @@
#requires -Version 5.1
<#
.SYNOPSIS
Bulk rename Intune policy/app displayNames and descriptions.
.DESCRIPTION
Search and replace names or descriptions across multiple Intune object types
in a single operation. Supports regex search/replace and prefix add/strip.
Integrates with the IntuneManagement headless auth stack.
.EXAMPLE
./Scripts/Bulk-RenamePolicies.ps1 -TenantId "contoso.onmicrosoft.com"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile,
[switch]$WhatIf
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Read-YesNo
{
param(
[string]$Prompt,
[bool]$Default = $false
)
$defaultChar = if($Default) { "Y" } else { "N" }
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
return $response -match "^\s*y"
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
#region Object type registry (editable types)
$editableTypes = @(
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; NameProp = "name"; DescProp = "description" },
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" }
)
#endregion
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Intune Bulk Rename Tool" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
#region Select object type
$typeTitles = $editableTypes | ForEach-Object { $_.Title }
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
$objectType = $editableTypes | Where-Object { $_.Title -eq $selectedTypeTitle } | Select-Object -First 1
#endregion
#region Load objects
Write-Host "`nLoading $($objectType.Title) objects..." -ForegroundColor Cyan
$api = "$($objectType.API)?`$select=id,$($objectType.NameProp),$(if($objectType.DescProp){$objectType.DescProp})&`$orderby=$($objectType.NameProp)"
$objectsResponse = Invoke-GraphRequest $api -AllPages
$objects = $objectsResponse.value | Where-Object { $_ } | Sort-Object $objectType.NameProp
Write-Host "Found $($objects.Count) objects." -ForegroundColor Green
$filter = Read-Host "`nFilter by current name (optional, press Enter to skip)"
if(-not [string]::IsNullOrWhiteSpace($filter))
{
$objects = $objects | Where-Object { $_."$($objectType.NameProp)" -like "*$filter*" }
Write-Host "Filtered to $($objects.Count) objects." -ForegroundColor Green
}
if($objects.Count -eq 0)
{
Write-Host "No objects found. Exiting." -ForegroundColor Yellow
exit 0
}
$objectDisplays = $objects | ForEach-Object { "$($_."$($objectType.NameProp)") [$($_.id)]" }
$selectedDisplays = Select-MenuItem -Items $objectDisplays -Header "Select objects to rename (multi-select)" -Multi
if(-not $selectedDisplays)
{
Write-Host "No objects selected. Exiting." -ForegroundColor Yellow
exit 0
}
$selectedObjects = @()
foreach($disp in $selectedDisplays)
{
$id = $disp -replace '.*\[(.*?)\]$', '$1'
$obj = $objects | Where-Object { $_.id -eq $id } | Select-Object -First 1
if($obj) { $selectedObjects += $obj }
}
Write-Host "Selected $($selectedObjects.Count) objects." -ForegroundColor Green
#endregion
#region Mutation options
$fieldToEdit = Select-MenuItem -Items @("displayName","description","both") -Header "Which field to edit?"
if(-not $fieldToEdit) { $fieldToEdit = "displayName" }
$mode = Select-MenuItem -Items @("Search and replace","Add prefix","Strip prefix") -Header "Select rename mode"
if(-not $mode) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
$searchPattern = ""
$replacePattern = ""
$prefix = ""
switch($mode)
{
"Search and replace"
{
$searchPattern = Read-Host "Enter search regex"
$replacePattern = Read-Host "Enter replacement string"
}
"Add prefix"
{
$prefix = Read-Host "Enter prefix to add"
}
"Strip prefix"
{
$prefix = Read-Host "Enter prefix to strip (will be removed from start)"
}
}
# Preview changes
Write-Host "`nPreview of changes:" -ForegroundColor Cyan
$changes = @()
foreach($obj in $selectedObjects)
{
$oldName = $obj."$($objectType.NameProp)"
$oldDesc = if($objectType.DescProp -and $obj.PSObject.Properties[$objectType.DescProp]) { $obj."$($objectType.DescProp)" } else { "" }
$newName = $oldName
$newDesc = $oldDesc
if($fieldToEdit -in @("displayName","both"))
{
switch($mode)
{
"Search and replace" { if($oldName -match $searchPattern) { $newName = $oldName -replace $searchPattern, $replacePattern } }
"Add prefix" { if(-not $oldName.StartsWith($prefix)) { $newName = "$prefix$oldName" } }
"Strip prefix" { if($oldName.StartsWith($prefix)) { $newName = $oldName.Substring($prefix.Length) } }
}
}
if($fieldToEdit -in @("description","both") -and $objectType.DescProp)
{
switch($mode)
{
"Search and replace" { if($oldDesc -match $searchPattern) { $newDesc = $oldDesc -replace $searchPattern, $replacePattern } }
"Add prefix" { if(-not $oldDesc.StartsWith($prefix)) { $newDesc = "$prefix$oldDesc" } }
"Strip prefix" { if($oldDesc.StartsWith($prefix)) { $newDesc = $oldDesc.Substring($prefix.Length) } }
}
}
if($newName -ne $oldName -or $newDesc -ne $oldDesc)
{
$changes += [PSCustomObject]@{
Object = $obj
OldName = $oldName
NewName = $newName
OldDesc = $oldDesc
NewDesc = $newDesc
}
Write-Host " $($oldName)" -ForegroundColor DarkGray
if($newName -ne $oldName) { Write-Host " -> Name: $newName" -ForegroundColor Green }
if($newDesc -ne $oldDesc) { Write-Host " -> Desc: $newDesc" -ForegroundColor Green }
}
}
if($changes.Count -eq 0)
{
Write-Host "No objects would be changed. Exiting." -ForegroundColor Yellow
exit 0
}
$confirm = Read-Host "`nProceed with renaming $($changes.Count) objects? [Y/n]"
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
{
Write-Host "Cancelled." -ForegroundColor Yellow
exit 0
}
#endregion
#region Execute
$success = 0
$failed = 0
foreach($change in $changes)
{
$obj = $change.Object
$payload = @{}
if($fieldToEdit -in @("displayName","both") -and $change.NewName -ne $change.OldName)
{
$payload[$objectType.NameProp] = $change.NewName
}
if($fieldToEdit -in @("description","both") -and $objectType.DescProp -and $change.NewDesc -ne $change.OldDesc)
{
$payload[$objectType.DescProp] = $change.NewDesc
}
if($payload.Count -eq 0) { continue }
try
{
if($WhatIf)
{
Write-Host " WHATIF: Would update $($change.OldName)" -ForegroundColor Magenta
$success++
}
else
{
$body = $payload | ConvertTo-Json -Depth 10 -Compress
$maxRetries = 3
$retryDelay = 2
$renamed = $false
for($r = 1; $r -le $maxRetries; $r++)
{
try
{
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)" -HttpMethod PATCH -Content $body
Write-Host " OK: Renamed '$($change.OldName)' -> '$($change.NewName)'" -ForegroundColor Green
$success++
$renamed = $true
break
}
catch
{
$statusCode = $_.Exception.Response.StatusCode
if($r -lt $maxRetries -and ($statusCode -ge 500 -or $statusCode -eq 429))
{
Write-Host " Retry $r/$maxRetries after $retryDelay`s (HTTP $statusCode)..." -ForegroundColor DarkYellow
Start-Sleep -Seconds $retryDelay
}
else
{
throw
}
}
}
if(-not $renamed) { throw "Rename failed after $maxRetries attempts." }
}
}
catch
{
Write-Host " ERROR: Failed to rename '$($change.OldName)'. $($_.Exception.Message)" -ForegroundColor Red
$failed++
}
}
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Bulk Rename Complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Success : $success"
Write-Host " Failed : $failed"
#endregion
+74
View File
@@ -0,0 +1,74 @@
#requires -Version 7.0
<#
.SYNOPSIS
Converts a CIS M365 Benchmark v7.0.0 PDF into a YAML baseline manifest.
.DESCRIPTION
Extracts text from the draft CIS PDF, parses recommendations, and generates
a CISM365-v7.yaml baseline file ready for Deploy-CISM365Baseline.ps1.
Prerequisites:
- Python 3 with pypdf installed (script will create venv if needed)
- The draft PDF at the specified path
.PARAMETER PdfPath
Path to the CIS M365 v7.0.0 draft PDF.
.PARAMETER OutputPath
Path for the generated YAML file. Defaults to ./Baselines/CISM365-v7-Generated.yaml
.PARAMETER Prefix
Optional naming prefix for all generated policies.
.EXAMPLE
./Scripts/ConvertFrom-CISPDF.ps1 -PdfPath ~/Downloads/DRAFT_CIS_Microsoft_365_Foundations_Benchmark_v7.0.0.pdf
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$PdfPath,
[Parameter()]
[string]$OutputPath = "$PSScriptRoot/../Baselines/CISM365-v7-Generated.yaml",
[Parameter()]
[string]$Prefix = "CIS-v7-",
[Parameter()]
[ValidateSet('L1','L2','Both')]
[string]$Level = 'Both',
[Parameter()]
[ValidateSet('E3','E5','Both')]
[string]$License = 'Both'
)
$ErrorActionPreference = 'Stop'
# Resolve paths
$pdfPathResolved = Resolve-Path $PdfPath | Select-Object -ExpandProperty Path
$outputPathResolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
# Ensure Python venv exists
$venvPath = "$PSScriptRoot/../.venv-pdf"
$pythonExe = "$venvPath/bin/python3"
if (-not (Test-Path $pythonExe)) {
Write-Host "Creating Python virtual environment..." -ForegroundColor Yellow
python3 -m venv $venvPath
& "$venvPath/bin/pip" install pypdf | Out-Null
}
$pyScript = "$PSScriptRoot/_ConvertFrom-CISPDF.py"
if (-not (Test-Path $pyScript)) {
throw "Python converter script not found: $pyScript"
}
Write-Host "Converting PDF to YAML baseline..." -ForegroundColor Cyan
& $pythonExe $pyScript $pdfPathResolved $outputPathResolved $Prefix $Level $License
if ($LASTEXITCODE -eq 0) {
Write-Host "Done. Review the generated file before deploying." -ForegroundColor Green
} else {
throw "PDF conversion failed."
}
+148
View File
@@ -0,0 +1,148 @@
#requires -Version 7.0
<#
.SYNOPSIS
Converts an existing IntuneManagement export folder into a baseline YAML manifest.
.DESCRIPTION
Scans a toolkit export directory, infers policy types from folder names,
extracts display names from JSON files, and emits a baseline YAML skeleton
with empty assignment blocks ready for editing.
.EXAMPLE
./Scripts/ConvertTo-IntuneBaseline.ps1 -ExportPath ./Exports/2025-01-15 -OutputPath ./Baselines/mybaseline.yaml
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ExportPath,
[Parameter(Mandatory = $true)]
[string]$OutputPath,
[string]$BaselineName = "ConvertedBaseline"
)
$ErrorActionPreference = "Stop"
#region Dependency check
$yamlModule = Get-Module -ListAvailable -Name powershell-yaml | Select-Object -First 1
if(-not $yamlModule)
{
Write-Warning "powershell-yaml module not found. Installing..."
Install-Module powershell-yaml -Scope CurrentUser -Force
}
Import-Module powershell-yaml -Force
#endregion
$exportPathResolved = Resolve-Path $ExportPath | Select-Object -ExpandProperty Path
$outputPathResolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
if(-not (Test-Path $exportPathResolved))
{
throw "Export path not found: $ExportPath"
}
# Folder-to-type whitelist (matches toolkit export folders and baseline types)
$folderTypeMap = @{
"DeviceConfiguration" = "DeviceConfiguration"
"SettingsCatalog" = "SettingsCatalog"
"CompliancePolicies" = "CompliancePolicies"
"CompliancePoliciesV2" = "CompliancePoliciesV2"
"AdministrativeTemplates" = "AdministrativeTemplates"
"EndpointSecurity" = "EndpointSecurity"
"DeviceManagementIntents" = "DeviceManagementIntents"
"AppProtection" = "AppProtection"
"AppConfigurationManagedDevice" = "AppConfigurationManagedDevice"
"PlatformScripts" = "PlatformScripts"
"MacScripts" = "MacScripts"
"DeviceHealthScripts" = "DeviceHealthScripts"
"MacCustomAttributes" = "MacCustomAttributes"
"EnrollmentRestrictions" = "EnrollmentRestrictions"
"EnrollmentStatusPage" = "EnrollmentStatusPage"
"Autopilot" = "Autopilot"
"TermsAndConditions" = "TermsAndConditions"
"PolicySets" = "PolicySets"
"UpdatePolicies" = "UpdatePolicies"
"FeatureUpdates" = "FeatureUpdates"
"QualityUpdates" = "QualityUpdates"
"Applications" = "Applications"
}
$policies = @()
foreach($folder in Get-ChildItem -Path $exportPathResolved -Directory)
{
$folderName = $folder.Name
if(-not $folderTypeMap.ContainsKey($folderName))
{
Write-Verbose "Skipping unrecognized folder: $folderName"
continue
}
$typeName = $folderTypeMap[$folderName]
$nameProp = if($typeName -eq "SettingsCatalog" -or $typeName -eq "CompliancePoliciesV2") { "name" } else { "displayName" }
$jsonFiles = Get-ChildItem -Path $folder.FullName -Filter "*.json"
foreach($file in $jsonFiles)
{
# Skip *_Settings.json companion files
if($file.BaseName -like "*_Settings")
{
continue
}
try
{
$json = Get-Content $file.FullName -Raw | ConvertFrom-Json -Depth 10
$displayName = $json.$nameProp
if(-not $displayName)
{
$displayName = $json.displayName
}
if(-not $displayName)
{
$displayName = $file.BaseName
}
}
catch
{
Write-Warning "Could not parse $($file.FullName); using filename as display name."
$displayName = $file.BaseName
}
$relativePath = "." + $file.FullName.Substring($exportPathResolved.Length).Replace("\", "/")
$policies += [ordered]@{
sourcePath = $relativePath
type = $typeName
assignments = @()
}
Write-Host "Mapped: [$typeName] $displayName -> $relativePath"
}
}
if($policies.Count -eq 0)
{
throw "No convertible policies found in $exportPathResolved"
}
$baseline = [ordered]@{
baseline = [ordered]@{
name = $BaselineName
conflictResolution = "Skip"
whatIf = $false
tenantMutation = [ordered]@{
search = ""
replace = ""
}
groups = @()
policies = $policies
}
}
$yaml = ConvertTo-Yaml -Data $baseline
$yaml | Set-Content -Path $outputPathResolved -Encoding UTF8
Write-Host "`nBaseline skeleton written to: $outputPathResolved" -ForegroundColor Green
Write-Host "Policies found: $($policies.Count)" -ForegroundColor Green
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host " 1. Edit the YAML to add group names and assignments."
Write-Host " 2. Run Deploy-IntuneBaseline.ps1 against a target tenant."
+112
View File
@@ -0,0 +1,112 @@
<#
.SYNOPSIS
Creates a Microsoft Entra app registration for headless Intune export/import.
.DESCRIPTION
Uses the Microsoft Graph PowerShell SDK to create an app, add required Graph
permissions, generate a client secret, and output the values needed for
AppOnly authentication.
Requires: Microsoft.Graph.Authentication, Microsoft.Graph.Applications
Install if missing: Install-Module Microsoft.Graph -Scope CurrentUser
#>
[CmdletBinding()]
param(
[string]$DisplayName = "IntuneManagement-Headless",
[ValidateSet("Export","Import","Both")]
[string]$PermissionLevel = "Both"
)
$requiredModules = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Applications")
foreach ($mod in $requiredModules) {
if (-not (Get-Module $mod -ListAvailable)) {
throw "Module '$mod' is not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser"
}
}
Import-Module Microsoft.Graph.Authentication -Force
Import-Module Microsoft.Graph.Applications -Force
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Write-Host "A browser window will open for authentication." -ForegroundColor Cyan
Connect-MgGraph -Scopes "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All" -NoWelcome
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
if (-not $graphSp) {
throw "Could not retrieve Microsoft Graph service principal."
}
$exportRoles = @(
"DeviceManagementApps.Read.All",
"DeviceManagementConfiguration.Read.All",
"DeviceManagementManagedDevices.Read.All",
"DeviceManagementScripts.Read.All",
"DeviceManagementServiceConfig.Read.All",
"Group.Read.All",
"Organization.Read.All"
)
$importRoles = @(
"DeviceManagementApps.ReadWrite.All",
"DeviceManagementConfiguration.ReadWrite.All",
"DeviceManagementManagedDevices.ReadWrite.All",
"DeviceManagementScripts.ReadWrite.All",
"DeviceManagementServiceConfig.ReadWrite.All",
"Group.ReadWrite.All",
"Organization.Read.All"
)
$roles = switch ($PermissionLevel) {
"Export" { $exportRoles }
"Import" { $importRoles }
"Both" { ($exportRoles + $importRoles) | Select-Object -Unique }
}
$resourceAccess = @()
foreach ($roleName in $roles) {
$appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $roleName } | Select-Object -First 1
if (-not $appRole) {
Write-Warning "Could not find app role: $roleName"
continue
}
$resourceAccess += @{
id = $appRole.Id
type = "Role"
}
}
$appParams = @{
DisplayName = $DisplayName
SignInAudience = "AzureADMyOrg"
RequiredResourceAccess = @(@{
resourceAppId = "00000003-0000-0000-c000-000000000000"
resourceAccess = $resourceAccess
})
}
Write-Host "Creating application '$DisplayName'..." -ForegroundColor Cyan
$app = New-MgApplication @appParams
Write-Host "Creating service principal..." -ForegroundColor Cyan
$sp = New-MgServicePrincipal -AppId $app.AppId
Write-Host "Adding client secret..." -ForegroundColor Cyan
$passwordCred = @{
displayName = "IntuneManagementSecret"
endDateTime = (Get-Date).AddYears(1)
}
$secret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred
Write-Host "`n=============================================================" -ForegroundColor Green
Write-Host "App Registration created successfully!" -ForegroundColor Green
Write-Host "=============================================================" -ForegroundColor Green
Write-Host "TenantId : $(Get-MgContext | Select-Object -ExpandProperty TenantId)"
Write-Host "AppId : $($app.AppId)"
Write-Host "Secret : $($secret.SecretText)"
Write-Host "=============================================================" -ForegroundColor Green
Write-Host "IMPORTANT: Go to the Entra portal > API Permissions and click" -ForegroundColor Yellow
Write-Host " 'Grant admin consent for <tenant>' before using" -ForegroundColor Yellow
Write-Host " the app for Export or Import." -ForegroundColor Yellow
Write-Host "=============================================================" -ForegroundColor Green
Disconnect-MgGraph | Out-Null
File diff suppressed because it is too large Load Diff
+679
View File
@@ -0,0 +1,679 @@
#requires -Version 7.0
<#
.SYNOPSIS
Deploys a declarative Intune baseline from a YAML manifest.
.DESCRIPTION
Reads a baseline YAML file, creates missing groups, imports policies,
applies name mutations, and assigns objects — all in a single command.
Ideal for seeding new tenants with OpenIntuneBaseline-style configurations.
.EXAMPLE
./Scripts/Deploy-IntuneBaseline.ps1 -BaselinePath ./Baselines/mybaseline.yaml -TenantId "contoso.onmicrosoft.com"
.EXAMPLE
./Scripts/Deploy-IntuneBaseline.ps1 -BaselinePath ./Baselines/mybaseline.yaml -WhatIf
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$BaselinePath,
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile,
[switch]$WhatIf,
[string]$ReportPath
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
function Resolve-RelativePath
{
param([string]$Path, [string]$BasePath)
if([System.IO.Path]::IsPathRooted($Path)) { return $Path }
$baseDir = Split-Path -Parent $BasePath
return Join-Path $baseDir $Path
}
function Test-YamlModule
{
return [bool](Get-Module -ListAvailable -Name powershell-yaml)
}
function Install-YamlModule
{
Write-Host "powershell-yaml module is required but not installed." -ForegroundColor Yellow
if(-not $WhatIf)
{
$confirm = Read-Host "Install powershell-yaml from PSGallery now? [Y/n]"
if($confirm -match "^\s*n")
{
throw "powershell-yaml is required. Install it with: Install-Module powershell-yaml -Scope CurrentUser -Force"
}
Install-Module powershell-yaml -Scope CurrentUser -Force
Import-Module powershell-yaml -Force
}
}
function Get-BaselineTypeMap
{
return @{
"Applications" = @{ API = "/deviceAppManagement/mobileApps"; AssignmentsType = "mobileAppAssignments"; AssignmentODataType = "#microsoft.graph.mobileAppAssignment"; HasIntent = $true; NameProp = "displayName"; SettingsAPI = $null }
"DeviceConfiguration" = @{ API = "/deviceManagement/deviceConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"SettingsCatalog" = @{ API = "/deviceManagement/configurationPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementConfigurationPolicyAssignment"; HasIntent = $false; NameProp = "name"; SettingsAPI = $null }
"CompliancePolicies" = @{ API = "/deviceManagement/deviceCompliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"CompliancePoliciesV2" = @{ API = "/deviceManagement/compliancePolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceCompliancePolicyAssignment"; HasIntent = $false; NameProp = "name"; SettingsAPI = $null }
"AdministrativeTemplates" = @{ API = "/deviceManagement/groupPolicyConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.groupPolicyConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"EndpointSecurity" = @{ API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = "updateSettings" }
"DeviceManagementIntents" = @{ API = "/deviceManagement/intents"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.deviceManagementIntentAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = "updateSettings" }
"AppProtection" = @{ API = "/deviceAppManagement/managedAppPolicies"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.targetedManagedAppPolicyAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"AppConfigurationManagedDevice" = @{ API = "/deviceAppManagement/mobileAppConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.managedDeviceMobileAppConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"PlatformScripts" = @{ API = "/deviceManagement/deviceManagementScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"MacScripts" = @{ API = "/deviceManagement/deviceShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"DeviceHealthScripts" = @{ API = "/deviceManagement/deviceHealthScripts"; AssignmentsType = "deviceHealthScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceHealthScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"MacCustomAttributes" = @{ API = "/deviceManagement/deviceCustomAttributeShellScripts"; AssignmentsType = "deviceManagementScriptAssignments"; AssignmentODataType = "#microsoft.graph.deviceManagementScriptAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"EnrollmentRestrictions" = @{ API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"EnrollmentStatusPage" = @{ API = "/deviceManagement/deviceEnrollmentConfigurations"; AssignmentsType = "enrollmentConfigurationAssignments"; AssignmentODataType = "#microsoft.graph.enrollmentConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"Autopilot" = @{ API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsAutopilotDeploymentProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"TermsAndConditions" = @{ API = "/deviceManagement/termsAndConditions"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.termsAndConditionsAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"PolicySets" = @{ API = "/deviceAppManagement/policySets"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.policySetAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"UpdatePolicies" = @{ API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsUpdateForBusinessConfigurationAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"FeatureUpdates" = @{ API = "/deviceManagement/windowsFeatureUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsFeatureUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
"QualityUpdates" = @{ API = "/deviceManagement/windowsQualityUpdateProfiles"; AssignmentsType = "assignments"; AssignmentODataType = "#microsoft.graph.windowsQualityUpdateProfileAssignment"; HasIntent = $false; NameProp = "displayName"; SettingsAPI = $null }
}
}
function Invoke-SanitizeObject
{
param($Obj)
$propsToRemove = @("id","createdDateTime","lastModifiedDateTime","source","status","version","isAssigned","publishingState")
foreach($prop in $propsToRemove)
{
if($Obj.PSObject.Properties[$prop])
{
$Obj.PSObject.Properties.Remove($prop)
}
}
return $Obj
}
function Invoke-ApplyMutation
{
param($Obj, $NameProp, [hashtable]$Mutation)
if(-not $Mutation) { return $Obj }
$search = $Mutation["search"]
$replace = $Mutation["replace"]
$prefix = $Mutation["prefix"]
foreach($prop in @($NameProp, "description"))
{
if($Obj.PSObject.Properties[$prop] -and $Obj.$prop)
{
$val = $Obj.$prop
if($search -and $replace)
{
$val = $val -replace $search, $replace
}
elseif($prefix)
{
if(-not $val.StartsWith($prefix))
{
$val = "$prefix$val"
}
}
$Obj.$prop = $val
}
}
return $Obj
}
function Get-ExistingObject
{
param($Api, $NameProp, $NameValue)
$escaped = $NameValue -replace "'","''"
$filter = "$NameProp eq '$escaped'"
$url = "$Api`?`$filter=$filter"
try
{
$resp = Invoke-GraphRequest -Url $url
if($resp.value -and $resp.value.Count -gt 0)
{
return $resp.value[0]
}
}
catch
{
# Some APIs don't support $filter; swallow and return null
}
return $null
}
function New-CloudOnlyGroup
{
param([string]$DisplayName, [string]$MailNickname, [bool]$SecurityEnabled = $true)
$body = @{
displayName = $DisplayName
mailEnabled = $false
mailNickname = $MailNickname
securityEnabled = $SecurityEnabled
} | ConvertTo-Json -Depth 5
return Invoke-GraphRequest -Url "/groups" -HttpMethod POST -Content $body
}
function Invoke-DeployAssignments
{
param(
[string]$ObjectId,
[hashtable]$TypeMeta,
[array]$Assignments,
[hashtable]$GroupCache,
[switch]$WhatIf
)
if(-not $Assignments -or $Assignments.Count -eq 0) { return }
$assignmentList = @()
foreach($ass in $Assignments)
{
$targetType = $ass["targetType"]
$groupName = $ass["groupName"]
$intent = $ass["intent"]
$odataType = switch($targetType)
{
"Group" { "#microsoft.graph.groupAssignmentTarget" }
"AllUsers" { "#microsoft.graph.allLicensedUsersAssignmentTarget" }
"AllDevices" { "#microsoft.graph.allDevicesAssignmentTarget" }
"ExcludeGroup" { "#microsoft.graph.exclusionGroupAssignmentTarget" }
default { throw "Unknown targetType: $targetType" }
}
$targetPayload = @{
"@odata.type" = $odataType
}
if($targetType -in @("Group","ExcludeGroup"))
{
if(-not $groupName) { throw "groupName is required for targetType $targetType" }
$gid = $GroupCache[$groupName]
if(-not $gid) { throw "Group '$groupName' not found in cache" }
$targetPayload["groupId"] = $gid
}
$payload = @{
"@odata.type" = $TypeMeta.AssignmentODataType
target = $targetPayload
}
if($TypeMeta.HasIntent -and $intent)
{
$payload["intent"] = $intent.ToString().ToLower()
}
$assignmentList += $payload
}
if($assignmentList.Count -eq 0) { return }
$assignBody = @{
$TypeMeta.AssignmentsType = $assignmentList
} | ConvertTo-Json -Depth 50 -Compress
$assignUrl = "$($TypeMeta.API)/$ObjectId/assign"
if($WhatIf)
{
Write-Host " [WHATIF] Would assign $($assignmentList.Count) target(s) to $assignUrl" -ForegroundColor Magenta
return
}
$null = Invoke-GraphRequest -Url $assignUrl -HttpMethod POST -Content $assignBody
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
#region Dependency check
if(-not (Test-YamlModule))
{
Install-YamlModule
}
Import-Module powershell-yaml -Force
#endregion
#region Load and validate baseline
$baselinePathResolved = Resolve-Path $BaselinePath | Select-Object -ExpandProperty Path
if(-not (Test-Path $baselinePathResolved))
{
throw "Baseline file not found: $BaselinePath"
}
Write-Host "`nLoading baseline: $baselinePathResolved" -ForegroundColor Cyan
$yamlText = Get-Content $baselinePathResolved -Raw
$yamlRoot = ConvertFrom-Yaml -Yaml $yamlText
if(-not $yamlRoot -or -not $yamlRoot.ContainsKey("baseline"))
{
throw "Invalid baseline YAML: missing 'baseline' root node."
}
$baseline = $yamlRoot["baseline"]
$conflictResolution = if($baseline.ContainsKey("conflictResolution")) { $baseline["conflictResolution"] } else { "Skip" }
$baselineWhatIf = if($baseline.ContainsKey("whatIf")) { [bool]$baseline["whatIf"] } else { $false }
$effectiveWhatIf = $WhatIf.IsPresent -or $baselineWhatIf
$globalMutation = $null
if($baseline.ContainsKey("tenantMutation"))
{
$globalMutation = $baseline["tenantMutation"]
}
Write-Host "Baseline name : $($baseline["name"])" -ForegroundColor Cyan
Write-Host "Conflict mode : $conflictResolution" -ForegroundColor Cyan
if($effectiveWhatIf) { Write-Host "*** DRY-RUN MODE ENABLED ***" -ForegroundColor Magenta }
#endregion
#region Resolve / create groups
$groupCache = @{}
Write-Host "`nLoading group directory..." -ForegroundColor Cyan
$allGroupsData = (Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages).value
if($baseline.ContainsKey("groups") -and $baseline["groups"])
{
Write-Host "Resolving baseline groups..." -ForegroundColor Cyan
$existingGroups = $allGroupsData
foreach($grpDef in $baseline["groups"])
{
$displayName = $grpDef["displayName"]
$existing = $existingGroups | Where-Object { $_.displayName -eq $displayName } | Select-Object -First 1
if($existing)
{
Write-Host " Group exists: $displayName ($($existing.id))" -ForegroundColor Green
$groupCache[$displayName] = $existing.id
}
else
{
$mailNick = $grpDef["mailNickname"]
$secEnabled = if($grpDef.ContainsKey("securityEnabled")) { [bool]$grpDef["securityEnabled"] } else { $true }
if($effectiveWhatIf)
{
Write-Host " [WHATIF] Would create group: $displayName" -ForegroundColor Magenta
$groupCache[$displayName] = "WHATIF-$displayName"
}
else
{
Write-Host " Creating group: $displayName" -ForegroundColor Yellow
$newGrp = New-CloudOnlyGroup -DisplayName $displayName -MailNickname $mailNick -SecurityEnabled $secEnabled
$groupCache[$displayName] = $newGrp.id
Write-Host " Created: $($newGrp.id)" -ForegroundColor Green
}
}
}
}
#endregion
#region Pre-load all existing groups for assignment resolution
foreach($g in $allGroupsData)
{
if(-not $groupCache.ContainsKey($g.displayName))
{
$groupCache[$g.displayName] = $g.id
}
}
#endregion
#region Process policies
$typeMap = Get-BaselineTypeMap
$stats = @{
Created = 0
Updated = 0
Skipped = 0
Failed = 0
Assigned = 0
}
$policyResults = [System.Collections.Generic.List[PSCustomObject]]::new()
if($baseline.ContainsKey("policies") -and $baseline["policies"])
{
$policies = $baseline["policies"]
Write-Host "`nDeploying $($policies.Count) policy(ies)..." -ForegroundColor Cyan
foreach($policyDef in $policies)
{
$sourcePath = Resolve-RelativePath -Path $policyDef["sourcePath"] -BasePath $baselinePathResolved
$typeName = $policyDef["type"]
if(-not $typeMap.ContainsKey($typeName))
{
Write-Warning "Unknown policy type '$typeName'. Skipping."
$stats.Failed++
continue
}
$typeMeta = $typeMap[$typeName]
if(-not (Test-Path $sourcePath))
{
Write-Warning "Policy file not found: $sourcePath"
$stats.Failed++
continue
}
try
{
$jsonRaw = Get-Content $sourcePath -Raw
$policyObj = $jsonRaw | ConvertFrom-Json -Depth 100
# Sanitize
$policyObj = Invoke-SanitizeObject -Obj $policyObj
# Mutate
$mutation = $globalMutation
if($policyDef.ContainsKey("mutation"))
{
$mutation = $policyDef["mutation"]
}
$policyObj = Invoke-ApplyMutation -Obj $policyObj -NameProp $typeMeta.NameProp -Mutation $mutation
$mutatedName = $policyObj.($typeMeta.NameProp)
Write-Host "`nPolicy: $mutatedName [$typeName]" -ForegroundColor Cyan
# Idempotency check
$existingObj = Get-ExistingObject -Api $typeMeta.API -NameProp $typeMeta.NameProp -NameValue $mutatedName
$objectId = $null
$shouldAssign = $false
$outcomeStatus = $null
$outcomeObjectId = $null
if($existingObj)
{
Write-Host " Existing object found: $($existingObj.id)" -ForegroundColor Yellow
if($conflictResolution -eq "Error")
{
throw "Conflict: object '$mutatedName' already exists and conflictResolution is Error."
}
elseif($conflictResolution -eq "Skip")
{
Write-Host " Skipping import (Skip mode)." -ForegroundColor Yellow
$objectId = $existingObj.id
$shouldAssign = $true # still apply assignments to existing object
$stats.Skipped++
$outcomeStatus = "Skipped"; $outcomeObjectId = $existingObj.id
}
elseif($conflictResolution -eq "Update")
{
if($effectiveWhatIf)
{
Write-Host " [WHATIF] Would PATCH existing object $($existingObj.id)" -ForegroundColor Magenta
$outcomeStatus = "WhatIf-Update"
}
else
{
$patchBody = $policyObj | Select-Object * | ConvertTo-Json -Depth 50
$null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content $patchBody
Write-Host " Updated existing object." -ForegroundColor Green
$outcomeStatus = "Updated"
}
$objectId = $existingObj.id
$shouldAssign = $true
$stats.Updated++
$outcomeObjectId = $existingObj.id
}
elseif($conflictResolution -eq "Merge")
{
if($effectiveWhatIf)
{
Write-Host " [WHATIF] Would PATCH (merge) existing object $($existingObj.id)" -ForegroundColor Magenta
$outcomeStatus = "WhatIf-Merge"
}
else
{
$mergeBody = @{}
foreach($prop in $policyObj.PSObject.Properties)
{
$mergeBody[$prop.Name] = $prop.Value
}
$null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content ($mergeBody | ConvertTo-Json -Depth 50)
Write-Host " Merged into existing object." -ForegroundColor Green
$outcomeStatus = "Merged"
}
$objectId = $existingObj.id
$shouldAssign = $true
$stats.Updated++
$outcomeObjectId = $existingObj.id
}
}
else
{
if($effectiveWhatIf)
{
Write-Host " [WHATIF] Would POST new object to $($typeMeta.API)" -ForegroundColor Magenta
$objectId = "WHATIF-NEW"
$shouldAssign = $true
$stats.Created++
$outcomeStatus = "WhatIf-Create"
}
else
{
$postBody = $policyObj | ConvertTo-Json -Depth 50
$newObj = Invoke-GraphRequest -Url $typeMeta.API -HttpMethod POST -Content $postBody
$objectId = $newObj.id
Write-Host " Created: $objectId" -ForegroundColor Green
$shouldAssign = $true
$stats.Created++
$outcomeStatus = "Created"; $outcomeObjectId = $newObj.id
# Secondary settings upload (EndpointSecurity / DeviceManagementIntents)
if($typeMeta.SettingsAPI)
{
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($sourcePath)
$settingsPathCandidate = Join-Path (Split-Path -Parent $sourcePath) "$baseName`_Settings.json"
if(Test-Path $settingsPathCandidate)
{
$settingsRaw = Get-Content $settingsPathCandidate -Raw
$settingsJson = $settingsRaw | ConvertFrom-Json
# The toolkit exports settings as { "settings": [...] }
$settingsBody = $settingsJson | ConvertTo-Json -Depth 50
$settingsUrl = "$($typeMeta.API)/$objectId/$($typeMeta.SettingsAPI)"
Write-Host " Uploading settings from $settingsPathCandidate" -ForegroundColor Cyan
$null = Invoke-GraphRequest -Url $settingsUrl -HttpMethod POST -Content $settingsBody
}
}
}
}
# Assignments
if($shouldAssign -and $policyDef.ContainsKey("assignments"))
{
Invoke-DeployAssignments -ObjectId $objectId -TypeMeta $typeMeta -Assignments $policyDef["assignments"] -GroupCache $groupCache -WhatIf:$effectiveWhatIf
$stats.Assigned++
}
$policyResults.Add([PSCustomObject]@{
PolicyName = $mutatedName
Type = $typeName
SourcePath = $sourcePath
ObjectId = $outcomeObjectId
Outcome = $outcomeStatus
Error = $null
})
}
catch
{
Write-Warning "Failed to deploy policy '$sourcePath': $_"
$stats.Failed++
$policyResults.Add([PSCustomObject]@{
PolicyName = $mutatedName
Type = $typeName
SourcePath = $sourcePath
ObjectId = $null
Outcome = "Failed"
Error = $_.Exception.Message
})
}
}
}
#endregion
#region Summary
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "Baseline deployment summary" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Created : $($stats.Created)"
Write-Host "Updated : $($stats.Updated)"
Write-Host "Skipped : $($stats.Skipped)"
Write-Host "Assigned: $($stats.Assigned)"
Write-Host "Failed : $($stats.Failed)"
if($effectiveWhatIf)
{
Write-Host "`n*** This was a dry-run (WhatIf). No changes were made. ***" -ForegroundColor Magenta
}
if($policyResults.Count -gt 0)
{
$resolvedReportPath = if($ReportPath) { $ReportPath } else {
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($baselinePathResolved)
Join-Path (Split-Path -Parent $baselinePathResolved) "${baseName}_DeployReport_${ts}.csv"
}
$policyResults | Export-Csv -Path $resolvedReportPath -NoTypeInformation -Force
Write-Host "Report : $resolvedReportPath" -ForegroundColor Cyan
}
if(-not $effectiveWhatIf -and $policyResults.Count -gt 0)
{
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$manifestPolicies = $policyResults | Where-Object { $_.Outcome -in @("Created","Updated","Merged","Skipped") } | ForEach-Object {
$hash = $null
if($_.SourcePath -and (Test-Path $_.SourcePath))
{
$bytes = [System.IO.File]::ReadAllBytes($_.SourcePath)
$hash = [System.BitConverter]::ToString($sha256.ComputeHash($bytes)) -replace '-',''
}
[ordered]@{
policyName = $_.PolicyName
type = $_.Type
objectId = $_.ObjectId
sourcePath = $_.SourcePath
sourceHash = $hash
outcome = $_.Outcome
}
}
$sha256.Dispose()
$manifest = [ordered]@{
baselineName = $baseline["name"]
baselinePath = $baselinePathResolved
tenantId = $TenantId
deployedAt = (Get-Date -Format 'o')
policies = @($manifestPolicies)
}
$manifestPath = [System.IO.Path]::ChangeExtension($baselinePathResolved, "manifest.json")
$manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -Force
Write-Host "Manifest: $manifestPath" -ForegroundColor Cyan
}
#endregion
+173
View File
@@ -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()
+368
View File
@@ -0,0 +1,368 @@
#requires -Version 5.1
<#
.SYNOPSIS
Export Intune policy/app assignments to CSV or Markdown for documentation.
.DESCRIPTION
Generates a CSV or Markdown report of assignments for selected object types.
Useful for documentation, change tracking, and compliance audits.
.EXAMPLE
./Scripts/Export-AssignmentsToCsv.ps1 -TenantId "..." -Format Csv -OutputPath ./assignments.csv
./Scripts/Export-AssignmentsToCsv.ps1 -TenantId "..." -Format Markdown -OutputPath ./assignments.md
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[ValidateSet("Csv","Markdown")]
[string]$Format,
[Parameter(Mandatory = $true)]
[string]$OutputPath,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Initialize Runtime
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule))
{
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
}
$settingsPath = $SettingsFile
if(-not $settingsPath)
{
$settingsPath = Get-DefaultSettingsPath
}
# Pre-load auth from settings
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
{
try
{
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
{
$tenantNode = $settingsObj[$TenantId]
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
{
$AppId = $tenantNode["GraphAzureAppId"]
}
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
{
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
{
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if(-not $Secret -and $IsMacOS -and $AppId)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $settingsPath
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
}
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
Import-Module $runtimeModule -Force
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
#endregion
#region Ensure Graph connectivity
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
{
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
}
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try
{
$org = Invoke-GraphRequest "/organization"
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
}
catch
{
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
}
#endregion
#region Object type registry
$assignableTypes = @(
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; HasIntent = $true; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; HasIntent = $false; NameProp = "name" },
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; HasIntent = $false; NameProp = "displayName" },
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; HasIntent = $false; NameProp = "displayName" }
)
#endregion
#region Select types and gather data
$typeTitles = $assignableTypes | ForEach-Object { $_.Title }
$selectedTypeTitles = Select-MenuItem -Items $typeTitles -Header "Select object types to export (multi-select)" -Multi
if(-not $selectedTypeTitles)
{
Write-Host "No types selected. Exiting." -ForegroundColor Yellow
exit 0
}
Write-Host "`nLoading groups for name resolution..." -ForegroundColor Cyan
$groupsResponse = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
$groups = $groupsResponse.value
$reportRows = @()
foreach($typeTitle in $selectedTypeTitles)
{
$objectType = $assignableTypes | Where-Object { $_.Title -eq $typeTitle } | Select-Object -First 1
Write-Host "`nExporting $($objectType.Title) assignments..." -ForegroundColor Cyan
try
{
$objectsResponse = Invoke-GraphRequest "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
$objects = $objectsResponse.value | Where-Object { $_ }
foreach($obj in $objects)
{
try
{
$assignmentsResponse = Invoke-GraphRequest "$($objectType.API)/$($obj.id)/assignments"
foreach($ass in $assignmentsResponse.value)
{
$targetType = $ass.target."@odata.type"
$targetName = "Unknown"
$groupId = $ass.target.groupId
if($targetType -eq "#microsoft.graph.groupAssignmentTarget")
{
$grp = $groups | Where-Object { $_.id -eq $groupId } | Select-Object -First 1
$targetName = if($grp) { $grp.displayName } else { $groupId }
}
elseif($targetType -eq "#microsoft.graph.exclusionGroupAssignmentTarget")
{
$grp = $groups | Where-Object { $_.id -eq $groupId } | Select-Object -First 1
$targetName = if($grp) { "Exclude: $($grp.displayName)" } else { "Exclude: $groupId" }
}
elseif($targetType -eq "#microsoft.graph.allLicensedUsersAssignmentTarget")
{
$targetName = "All Users"
}
elseif($targetType -eq "#microsoft.graph.allDevicesAssignmentTarget")
{
$targetName = "All Devices"
}
$filterName = ""
if($ass.target.deviceAndAppManagementAssignmentFilterId)
{
$filterName = $ass.target.deviceAndAppManagementAssignmentFilterId
}
$intent = ""
if($objectType.HasIntent -and $ass.intent)
{
$intent = $ass.intent
}
$reportRows += [PSCustomObject]@{
ObjectType = $objectType.Title
ObjectName = if($objectType.NameProp -eq "name") { $obj.name } else { $obj.displayName }
ObjectId = $obj.id
Target = $targetName
TargetType = $targetType
Intent = $intent
Filter = $filterName
}
}
}
catch
{
# suppress per-object errors
}
}
}
catch
{
Write-Host " WARNING: Could not load objects for $($objectType.Title)" -ForegroundColor DarkYellow
}
}
if($reportRows.Count -eq 0)
{
Write-Host "No assignments found to export. Exiting." -ForegroundColor Yellow
exit 0
}
#endregion
#region Export
$OutputPath = (Resolve-Path (Split-Path -Parent $OutputPath) -ErrorAction SilentlyContinue).Path + "/" + (Split-Path -Leaf $OutputPath)
if($Format -eq "Csv")
{
$reportRows | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding utf8 -Force
Write-Host "`nExported $($reportRows.Count) rows to CSV: $OutputPath" -ForegroundColor Green
}
elseif($Format -eq "Markdown")
{
$md = @()
$md += "# Intune Assignments Report"
$md += ""
$md += "**Tenant:** $($org.value[0].displayName) "
$md += "**Generated:** $(Get-Date -Format "yyyy-MM-dd HH:mm") "
$md += "**Total Rows:** $($reportRows.Count)"
$md += ""
$grouped = $reportRows | Group-Object -Property ObjectType
foreach($g in $grouped)
{
$md += "## $($g.Name)"
$md += ""
$md += "| Object | Target | Intent | Filter |"
$md += "|--------|--------|--------|--------|"
foreach($row in ($g.Group | Sort-Object ObjectName, Target))
{
$intentCol = if($row.Intent) { $row.Intent } else { "-" }
$filterCol = if($row.Filter) { $row.Filter } else { "-" }
$md += "| $($row.ObjectName) | $($row.Target) | $intentCol | $filterCol |"
}
$md += ""
}
$md | Out-File -LiteralPath $OutputPath -Encoding utf8 -Force
Write-Host "`nExported $($reportRows.Count) rows to Markdown: $OutputPath" -ForegroundColor Green
}
#endregion
+438
View File
@@ -0,0 +1,438 @@
#requires -Version 7.0
<#
.SYNOPSIS
Export all active and PIM-eligible memberships in Microsoft Entra directory
roles, including expansion of group-assigned roles to transitive group
members, and mark whether each role is privileged.
.DESCRIPTION
Uses Microsoft Graph PowerShell (Microsoft.Graph.Authentication) and the
beta roleManagement endpoints so the isPrivileged flag is available for
both built-in and custom roles. Follows this toolkit's standard
-AuthMode / -AppId / -Secret / -Certificate auth convention (see
Deploy-CISM365Baseline.ps1).
.REQUIREMENTS
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser
Install-Module Microsoft.Graph.DirectoryObjects -Scope CurrentUser
Install-Module Microsoft.Graph.Groups -Scope CurrentUser
.PERMISSIONS
Suggested Graph scopes / app roles:
RoleManagement.Read.Directory
Directory.Read.All
GroupMember.Read.All
.EXAMPLE
./Scripts/Export-EntraRoleMembership.ps1 -TenantId <tenant-id> -AuthMode Browser
.EXAMPLE
./Scripts/Export-EntraRoleMembership.ps1 -TenantId <tenant-id> -AuthMode AppOnly -AppId <app-id> -Secret <secret> -CsvPath ./reports/roles.csv
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$TenantId,
[Parameter()]
[string]$AppId,
[Parameter()]
[string]$Secret,
[Parameter()]
[string]$Certificate,
[Parameter()]
[ValidateSet('AppOnly', 'Browser', 'DeviceCode')]
[string]$AuthMode = 'Browser',
[Parameter()]
[string]$SettingsFile,
[Parameter()]
[string]$CsvPath
)
$ErrorActionPreference = "Stop"
#region Helpers
function Get-ObjectType {
param([object]$Object)
if ($null -eq $Object.AdditionalProperties.'@odata.type') {
return $Object.GetType().Name
}
return ($Object.AdditionalProperties.'@odata.type' -replace '^#microsoft\.graph\.', '')
}
function Get-DisplayNameSafe {
param([object]$Object)
if ($Object.PSObject.Properties.Name -contains 'DisplayName' -and $Object.DisplayName) {
return $Object.DisplayName
}
if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('displayName')) {
return $Object.AdditionalProperties['displayName']
}
return $null
}
function Get-UpnSafe {
param([object]$Object)
if ($Object.PSObject.Properties.Name -contains 'UserPrincipalName' -and $Object.UserPrincipalName) {
return $Object.UserPrincipalName
}
if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('userPrincipalName')) {
return $Object.AdditionalProperties['userPrincipalName']
}
return $null
}
function Get-MailSafe {
param([object]$Object)
if ($Object.PSObject.Properties.Name -contains 'Mail' -and $Object.Mail) {
return $Object.Mail
}
if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('mail')) {
return $Object.AdditionalProperties['mail']
}
return $null
}
function Invoke-GraphGetAllPages {
param(
[Parameter(Mandatory)]
[string]$Uri
)
$items = @()
$nextLink = $Uri
while ($nextLink) {
$response = Invoke-MgGraphRequest -Method GET -Uri $nextLink -OutputType PSObject
if ($response.value) {
$items += $response.value
}
else {
$items += $response
break
}
$nextLink = $response.'@odata.nextLink'
}
return $items
}
#endregion
#region Load saved AppId/Secret (same lookup as Invoke-IntuneHeadlessBatch)
if ($AuthMode -eq 'AppOnly' -and $TenantId -and (-not $AppId -or (-not $Secret -and -not $Certificate))) {
$coreModule = Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1"
if (Test-Path $coreModule) {
Import-Module $coreModule -Force -Global
$settingsPath = if ($SettingsFile) { $SettingsFile } else { Join-Path (Get-CloudApiDataFolder) "Settings.json" }
if (Test-Path $settingsPath) {
try {
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if ($settingsObj -and $settingsObj.ContainsKey($TenantId)) {
$tenantNode = $settingsObj[$TenantId]
if (-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId")) {
$AppId = $tenantNode["GraphAzureAppId"]
}
if (-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret")) {
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if (-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert")) {
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if (-not $Secret -and $IsMacOS -and $AppId) {
try {
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if ($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
}
}
#endregion
#region Auth
$requiredModules = @(
"Microsoft.Graph.Authentication",
"Microsoft.Graph.Identity.Governance",
"Microsoft.Graph.DirectoryObjects",
"Microsoft.Graph.Groups"
)
foreach ($mod in $requiredModules) {
if (-not (Get-Module $mod -ListAvailable)) {
throw "Module '$mod' is not installed. Run: Install-Module $mod -Scope CurrentUser"
}
Import-Module $mod -Force
}
$GraphScopes = @("RoleManagement.Read.Directory", "Directory.Read.All", "GroupMember.Read.All")
Write-Host "Connecting to Microsoft Graph (mode: $AuthMode)..." -NoNewline
$connectParams = @{}
if ($TenantId) { $connectParams.TenantId = $TenantId }
switch ($AuthMode) {
'AppOnly' {
if (-not $AppId) { throw "AppId is required for AppOnly auth mode." }
if ($Secret) {
$secureSecret = ConvertTo-SecureString -String $Secret -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($AppId, $secureSecret)
$connectParams.ClientSecretCredential = $credential
}
elseif ($Certificate) {
$cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $Certificate -or $_.Subject -eq $Certificate } | Select-Object -First 1
if (-not $cert) { throw "Certificate not found: $Certificate" }
$connectParams.ClientCertificateCredential = $cert
}
else {
throw "Secret or Certificate is required for AppOnly auth mode."
}
Connect-MgGraph @connectParams -NoWelcome
}
'DeviceCode' {
Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -UseDeviceCode -NoWelcome
}
default { # Browser / Interactive
Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -NoWelcome
}
}
$context = Get-MgContext
Write-Host " OK ($($context.Account))" -ForegroundColor Green
#endregion
Write-Host "Getting tenant information..." -ForegroundColor Cyan
$org = Invoke-GraphGetAllPages -Uri "https://graph.microsoft.com/v1.0/organization?`$select=displayName,id"
$tenantName = $org[0].displayName
$tenantId = $org[0].id
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() -join ''
$escaped = [regex]::Escape($invalidChars)
$safeTenantName = $tenantName -replace "[$escaped]", '_'
$defaultFileName = "$safeTenantName-Entra-AllRoleMemberships.csv"
if ([string]::IsNullOrWhiteSpace($CsvPath)) {
$CsvPath = ".\$defaultFileName"
}
elseif ((Test-Path -LiteralPath $CsvPath -PathType Container) -or ($CsvPath -match '[\\/]$')) {
$CsvPath = Join-Path $CsvPath $defaultFileName
}
$csvDir = Split-Path -Parent $CsvPath
if ($csvDir -and -not (Test-Path -LiteralPath $csvDir)) {
New-Item -ItemType Directory -Path $csvDir -Force | Out-Null
}
Write-Host "Tenant: $tenantName ($tenantId)" -ForegroundColor Cyan
Write-Host "Export path: $CsvPath" -ForegroundColor Cyan
Write-Host ""
Write-Host "Getting all role definitions from Graph beta endpoint..." -ForegroundColor Cyan
$roleDefsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions"
$allRoles = Invoke-GraphGetAllPages -Uri $roleDefsUri
if (-not $allRoles) {
Write-Warning "No role definitions were returned."
return
}
Write-Host "Getting active role assignments from Graph beta endpoint..." -ForegroundColor Cyan
$assignmentsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$expand=roleDefinition"
$assignments = Invoke-GraphGetAllPages -Uri $assignmentsUri
Write-Host "Getting PIM-eligible role assignments from Graph beta endpoint..." -ForegroundColor Cyan
$eligibilityUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules"
try {
$eligibleAssignments = Invoke-GraphGetAllPages -Uri $eligibilityUri
}
catch {
$errorText = "$($_.Exception.Message) $($_.ErrorDetails.Message)"
if ($errorText -match 'AadPremiumLicenseRequired') {
Write-Warning "PIM eligibility schedules require Entra ID P2 or Governance license. Skipping eligible assignments."
$eligibleAssignments = @()
}
elseif ($errorText -match 'PermissionScopeNotGranted|Authorization_RequestDenied|403') {
Write-Warning "Missing permission for PIM eligibility schedules (needs RoleManagement.Read.Directory app role granted to this app registration). Skipping eligible assignments."
$eligibleAssignments = @()
}
else {
throw
}
}
$activeWithState = foreach ($a in $assignments) { $a | Select-Object *, @{N = 'AssignmentState'; E = { 'Active' } } }
$eligibleWithState = foreach ($e in $eligibleAssignments) { $e | Select-Object *, @{N = 'AssignmentState'; E = { 'Eligible' } } }
$allAssignments = $activeWithState + $eligibleWithState
Write-Host "Discovered $($allRoles.Count) role definitions, $($assignments.Count) active assignments, and $($eligibleAssignments.Count) eligible assignments." -ForegroundColor DarkGray
$roleMap = @{}
$rolePrivilegedMap = @{}
foreach ($role in $allRoles) {
$roleMap[$role.id] = $role.displayName
$rolePrivilegedMap[$role.id] = [bool]$role.isPrivileged
}
$principalCache = @{}
$groupMemberCache = @{}
$results = New-Object System.Collections.Generic.List[object]
foreach ($assignment in $allAssignments) {
$roleId = $assignment.roleDefinitionId
$roleName = $roleMap[$roleId]
$isPrivileged = $rolePrivilegedMap[$roleId]
# Fallback to the inline expanded role definition if the role wasn't in the catalog
if (-not $roleName -and $assignment.roleDefinition) {
$roleName = $assignment.roleDefinition.displayName
$isPrivileged = [bool]$assignment.roleDefinition.isPrivileged
}
$principalId = $assignment.principalId
$directoryScopeId = $assignment.directoryScopeId
$assignmentState = $assignment.AssignmentState
# Skip assignments whose role definition could not be resolved
if (-not $roleName) {
Write-Warning "Skipping assignment for unknown role definition $roleId"
continue
}
if (-not $principalCache.ContainsKey($principalId)) {
try {
$principalCache[$principalId] = Get-MgDirectoryObjectById -Ids $principalId
}
catch {
Write-Warning "Failed to resolve principal $principalId"
continue
}
}
$principal = $principalCache[$principalId]
$principalType = Get-ObjectType -Object $principal
$principalName = Get-DisplayNameSafe -Object $principal
switch ($principalType.ToLower()) {
"group" {
if (-not $groupMemberCache.ContainsKey($principalId)) {
try {
$groupMemberCache[$principalId] = Get-MgGroupTransitiveMember -GroupId $principalId -All
}
catch {
Write-Warning "Failed to expand group members for group $principalName ($principalId)"
$groupMemberCache[$principalId] = @()
}
}
$members = $groupMemberCache[$principalId]
if (-not $members -or $members.Count -eq 0) {
$results.Add([pscustomobject]@{
IsPrivileged = $isPrivileged
AssignmentState = $assignmentState
RoleName = $roleName
RoleId = $roleId
AssignmentType = "GroupAssignment"
AssignmentPrincipalType = $principalType
AssignmentPrincipal = $principalName
AssignmentPrincipalId = $principalId
ExpandedMemberType = $null
ExpandedMemberName = $null
ExpandedMemberUPN = $null
ExpandedMemberMail = $null
ExpandedMemberId = $null
DirectoryScopeId = $directoryScopeId
Notes = "Group assigned, but no transitive members returned"
})
}
else {
foreach ($member in $members) {
$memberType = Get-ObjectType -Object $member
$memberName = Get-DisplayNameSafe -Object $member
$memberUpn = Get-UpnSafe -Object $member
$memberMail = Get-MailSafe -Object $member
$results.Add([pscustomobject]@{
IsPrivileged = $isPrivileged
AssignmentState = $assignmentState
RoleName = $roleName
RoleId = $roleId
AssignmentType = "GroupAssignmentExpanded"
AssignmentPrincipalType = $principalType
AssignmentPrincipal = $principalName
AssignmentPrincipalId = $principalId
ExpandedMemberType = $memberType
ExpandedMemberName = $memberName
ExpandedMemberUPN = $memberUpn
ExpandedMemberMail = $memberMail
ExpandedMemberId = $member.Id
DirectoryScopeId = $directoryScopeId
Notes = "Expanded from group assignment"
})
}
}
}
default {
$results.Add([pscustomobject]@{
IsPrivileged = $isPrivileged
AssignmentState = $assignmentState
RoleName = $roleName
RoleId = $roleId
AssignmentType = "DirectAssignment"
AssignmentPrincipalType = $principalType
AssignmentPrincipal = $principalName
AssignmentPrincipalId = $principalId
ExpandedMemberType = $principalType
ExpandedMemberName = $principalName
ExpandedMemberUPN = Get-UpnSafe -Object $principal
ExpandedMemberMail = Get-MailSafe -Object $principal
ExpandedMemberId = $principalId
DirectoryScopeId = $directoryScopeId
Notes = "Direct role assignment"
})
}
}
}
$results |
Sort-Object IsPrivileged, AssignmentState, RoleName, AssignmentType, ExpandedMemberName |
Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Host ""
Write-Host "Exported $($results.Count) records to: $CsvPath" -ForegroundColor Green
Write-Host ""
$results |
Sort-Object IsPrivileged, AssignmentState, RoleName, ExpandedMemberName |
Format-Table IsPrivileged, AssignmentState, RoleName, AssignmentType, AssignmentPrincipal, ExpandedMemberName, ExpandedMemberUPN, ExpandedMemberType -AutoSize
Disconnect-MgGraph | Out-Null
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Generate an object inventory CSV from Intune backup JSON files.
One row per JSON object. Includes assignment summary columns.
Output columns: PolicyType, ObjectName, ObjectType, ObjectId, Description,
AssignmentState, AssignmentCount, IncludeTargets, ExcludeTargets,
SourceFile
"""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
from typing import Iterator
_EXCLUDED_DIRS = {"reports", "__archive__"}
_GROUP_TARGET_TYPES = {
"#microsoft.graph.groupAssignmentTarget",
"#microsoft.graph.exclusionGroupAssignmentTarget",
}
FIELDNAMES = [
"PolicyType",
"ObjectName",
"ObjectType",
"ObjectId",
"Description",
"AssignmentState",
"AssignmentCount",
"IncludeTargets",
"ExcludeTargets",
"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="object-inventory.csv",
help="Output CSV path (default: object-inventory.csv).")
return p.parse_args()
def _safe(value: object) -> str:
return "" if value is None else str(value).strip()
def _resolve_target_name(target: dict) -> tuple[str, str]:
"""Returns (intent, display_name)."""
ttype = _safe(target.get("@odata.type"))
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
return "include", "All devices"
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
return "include", "All users"
if ttype == "#microsoft.graph.exclusionGroupAssignmentTarget":
name = (_safe(target.get("groupDisplayName") or target.get("groupName")
or target.get("groupId")) or "Unresolved group")
return "exclude", name
if ttype in _GROUP_TARGET_TYPES:
name = (_safe(target.get("groupDisplayName") or target.get("groupName")
or target.get("groupId")) or "Unresolved group")
return "include", name
return "include", (_safe(target.get("groupDisplayName") or target.get("id"))
or "Unknown target")
def _summarize_assignments(payload: dict) -> dict[str, str]:
assignments = payload.get("assignments")
if not isinstance(assignments, list):
return {"AssignmentState": "NotExported", "AssignmentCount": "0",
"IncludeTargets": "", "ExcludeTargets": ""}
valid = [a for a in assignments if isinstance(a, dict)]
if not valid:
return {"AssignmentState": "Unassigned", "AssignmentCount": "0",
"IncludeTargets": "", "ExcludeTargets": ""}
include: list[str] = []
exclude: list[str] = []
for assignment in valid:
target = assignment.get("target") or {}
intent, name = _resolve_target_name(target)
explicit = _safe(assignment.get("intent")).lower()
if explicit == "exclude" or intent == "exclude":
exclude.append(name)
else:
include.append(name)
return {
"AssignmentState": "Assigned",
"AssignmentCount": str(len(valid)),
"IncludeTargets": "; ".join(sorted(set(include))),
"ExcludeTargets": "; ".join(sorted(set(exclude))),
}
def _iter_rows(root: Path) -> 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 ""
object_name = (_safe(payload.get("displayName")) or _safe(payload.get("name"))
or path.stem.split("__")[0])
assignment_summary = _summarize_assignments(payload)
yield {
"PolicyType": policy_type,
"ObjectName": object_name,
"ObjectType": _safe(payload.get("@odata.type")),
"ObjectId": _safe(payload.get("id")),
"Description": _safe(payload.get("description")),
"SourceFile": rel.as_posix(),
**assignment_summary,
}
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}")
rows = sorted(
_iter_rows(root),
key=lambda r: (r["PolicyType"].lower(), r["ObjectName"].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()
+44
View File
@@ -0,0 +1,44 @@
<#
.SYNOPSIS
Headless Intune policy export wrapper for macOS/Linux/Windows.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)]
[string]$ExportPath,
[string]$SettingsFile,
[string]$BatchFile,
[string]$NameFilter = "",
[string]$NameSearchPattern = "",
[string]$NameReplacePattern = "",
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
[switch]$IncludeAssignments,
[switch]$AddCompanyName
)
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
Import-Module $modulePath -Force
Export-IntunePolicies @PSBoundParameters
+687
View File
@@ -0,0 +1,687 @@
#!/usr/bin/env python3
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
Covers:
- Settings Catalog + Compliance Policies V2 (settingInstance structure)
- Endpoint Security / Device Management Intents (companion _Settings.json,
old intent API with definitionId + value/valueJson)
- Administrative Templates (companion _Settings.json, definitionValues)
- Device Configuration + Compliance Policies V1 (flat, with OMA-URI expansion)
- Scripts: PowerShell, Shell, Custom Attributes, Health Scripts
(scriptContent / detectionScriptContent / remediationScriptContent decoded from base64)
- App Protection, App Configuration App/Device (flat + customSettings expansion)
- Update, Enrollment, Autopilot, W365, Filters, and other flat types
Human-readable setting names resolved from configurationSettings.json when present.
Output columns: Policy, Platform, 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 base64
import csv
import json
import re
from pathlib import Path
from typing import Any, Optional
OUTPUT_FILE = "settings-report.csv"
BASE_FIELDNAMES = ["Policy", "Platform", "Setting", "Value"]
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
_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",
}
_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",
# settings arrays are handled by dedicated processors; avoid JSON blobs in flat categories
"settings",
}
# Expanded by dedicated helpers; excluded from generic flat key loop
_SPECIAL_KEYS = {"omaSettings", "customSettings"}
# Keys with base64-encoded text content
_B64_TEXT_KEYS = {"scriptContent", "detectionScriptContent", "remediationScriptContent", "payloadJson"}
_SCRIPT_PREVIEW_CHARS = 300
# ---------------------------------------------------------------------------
# Args
# ---------------------------------------------------------------------------
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
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:
platforms = policy.get("platforms")
if platforms:
return _normalize_platforms(platforms)
for key in ("platform", "platformType"):
val = policy.get(key)
if val:
return _normalize_platforms(val)
return _platform_from_odata(policy.get("@odata.type", ""))
# ---------------------------------------------------------------------------
# Assignment resolution
# ---------------------------------------------------------------------------
def _load_groups(root: Path) -> dict[str, str]:
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]:
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, platform: 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] = []
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({**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({**base_row, "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({**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({**base_row, "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, platform, parent=name))
return rows
# ---------------------------------------------------------------------------
# Intent settings walker (Endpoint Security / Device Management Intents)
# Old-style API: /deviceManagement/intents/{id}/settings
# Each item has definitionId + value/valueJson instead of settingInstance
# ---------------------------------------------------------------------------
def _intent_def_name(definition_id: str) -> str:
"""Human name from intent definitionId like 'category--type_settingName'."""
tail = definition_id.rsplit("_", 1)[-1]
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", tail).title()
def _walk_intent(si: dict, policy: str, platform: str, parent: str = "") -> list[dict]:
rows: list[dict] = []
defid = si.get("definitionId", "")
name = _intent_def_name(defid)
if parent:
name = f"{parent} > {name}"
base = {"Policy": policy, "Platform": platform}
value = si.get("value")
value_json = si.get("valueJson", "")
def _emit(v: Any) -> None:
if isinstance(v, list):
dict_children = [c for c in v if isinstance(c, dict)]
primitives = [c for c in v if not isinstance(c, dict)]
for child in dict_children:
rows.extend(_walk_intent(child, policy, platform, parent=name))
if primitives:
rows.append({**base, "Setting": name,
"Value": "; ".join(str(x) for x in primitives)})
elif v is not None:
rows.append({**base, "Setting": name, "Value": str(v)})
if value is not None:
_emit(value)
elif value_json and value_json != "null":
try:
_emit(json.loads(value_json))
except json.JSONDecodeError:
rows.append({**base, "Setting": name, "Value": value_json})
return rows
# ---------------------------------------------------------------------------
# OMA-URI and customSettings helpers
# ---------------------------------------------------------------------------
def _expand_oma_settings(oma_list: list, base_row: dict) -> list[dict]:
rows = []
for oma in oma_list:
if not isinstance(oma, dict):
continue
uri = oma.get("omaUri", "")
display = oma.get("displayName", "")
setting_name = f"{uri} ({display})" if display else uri
raw = oma.get("value")
if raw is None:
raw = oma.get("secretReferenceValueId", "")
if isinstance(raw, (bool, int, float)):
value_str = str(raw)
elif isinstance(raw, str):
value_str = raw
else:
value_str = json.dumps(raw, ensure_ascii=False) if raw is not None else ""
rows.append({**base_row, "Setting": setting_name, "Value": value_str})
return rows
def _expand_custom_settings(cs_list: list, base_row: dict) -> list[dict]:
rows = []
for cs in cs_list:
if not isinstance(cs, dict):
continue
sname = cs.get("name") or cs.get("key") or ""
value = str(cs.get("value") or "")
if sname:
rows.append({**base_row, "Setting": f"customSettings/{sname}", "Value": value})
return rows
def _decode_b64_text(b64_str: str) -> str:
try:
text = base64.b64decode(b64_str).decode("utf-8", errors="replace").strip()
if len(text) > _SCRIPT_PREVIEW_CHARS:
return text[:_SCRIPT_PREVIEW_CHARS] + f"… [{len(text)} chars]"
return text
except Exception:
return f"[base64 {len(b64_str)} chars]"
# ---------------------------------------------------------------------------
# Folder resolution
# ---------------------------------------------------------------------------
def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
for name in candidates:
p = root / name
if p.is_dir():
return p
return None
# ---------------------------------------------------------------------------
# Processors
# ---------------------------------------------------------------------------
def process_settings_catalog(root: Path, catalog: dict[str, Any],
groups: dict[str, str],
include_assignments: bool) -> list[dict]:
"""Settings Catalog + Compliance Policies V2 — both use settings[].settingInstance."""
folder_groups = [
("SettingsCatalog", "Settings Catalog"),
("CompliancePoliciesV2", "Compliance Policies - V2"),
]
rows: list[dict] = []
seen: set[Path] = set()
for candidates in folder_groups:
folder = _resolve_folder(root, *candidates)
if folder is None or folder in seen:
continue
seen.add(folder)
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy = json.load(f)
policy_name = policy.get("name") or policy.get("displayName") or path.stem
platform = _extract_platform(policy)
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, platform):
row.update(assignment_cols)
rows.append(row)
return rows
def process_intent_settings(root: Path, groups: dict[str, str],
include_assignments: bool) -> list[dict]:
"""Endpoint Security + Device Management Intents.
IntuneManagement exports policy metadata to <Name>.json and settings to
<Name>_Settings.json via the /deviceManagement/intents/{id}/settings endpoint.
Settings use the old intent format: definitionId + value/valueJson.
"""
folder_groups = [
("EndpointSecurity", "Endpoint Security"),
("DeviceManagementIntents", "Device Management Intents"),
]
rows: list[dict] = []
seen: set[Path] = set()
for candidates in folder_groups:
folder = _resolve_folder(root, *candidates)
if folder is None or folder in seen:
continue
seen.add(folder)
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy_obj = json.load(f)
if not isinstance(policy_obj, dict):
continue
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
platform = _extract_platform(policy_obj)
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
settings_path = path.parent / f"{path.stem}_Settings.json"
settings_list: list = []
if settings_path.is_file():
with settings_path.open(encoding="utf-8") as f:
sd = json.load(f)
settings_list = sd.get("settings", sd) if isinstance(sd, dict) else sd
for si in settings_list:
if isinstance(si, dict):
for row in _walk_intent(si, policy_name, platform):
row.update(assignment_cols)
rows.append(row)
return rows
def process_admx(root: Path, groups: dict[str, str],
include_assignments: bool) -> list[dict]:
"""Administrative Templates — definitionValues in companion _Settings.json.
IntuneManagement removes definitionValues from the main export file
(PropertiesToRemove) and saves them separately via Start-PostExportAdministrativeTemplate.
Each definitionValue has definition.displayName/categoryPath and presentationValues.
"""
folder = _resolve_folder(root, "AdministrativeTemplates", "Administrative Templates")
rows: list[dict] = []
if folder is None:
return rows
for path in sorted(folder.glob("*.json")):
if path.stem.endswith("_Settings"):
continue
with path.open(encoding="utf-8") as f:
policy_obj = json.load(f)
if not isinstance(policy_obj, dict):
continue
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
def_values: list = policy_obj.get("definitionValues", [])
if not def_values:
settings_path = path.parent / f"{path.stem}_Settings.json"
if settings_path.is_file():
with settings_path.open(encoding="utf-8") as f:
sd = json.load(f)
def_values = (sd.get("definitionValues", sd)
if isinstance(sd, dict) else sd)
for dv in def_values:
if not isinstance(dv, dict):
continue
defn = dv.get("definition") or {}
raw_name = defn.get("displayName") or defn.get("id", "")
cat = defn.get("categoryPath", "").strip("\\").replace("\\", " > ")
setting_name = f"{cat} > {raw_name}" if cat else raw_name
enabled = dv.get("enabled", True)
pres_values = dv.get("presentationValues", [])
if not enabled:
value_str = "Disabled"
elif not pres_values:
value_str = "Enabled"
else:
parts = []
for pv in pres_values:
if not isinstance(pv, dict):
continue
label = (pv.get("presentation") or {}).get("label") or ""
val = pv.get("value")
if isinstance(val, list):
val = "; ".join(str(v) for v in val)
else:
val = str(val) if val is not None else ""
parts.append(f"{label}: {val}" if label else val)
value_str = " | ".join(parts) if parts else "Enabled"
row = {"Policy": policy_name, "Platform": "Windows",
"Setting": setting_name, "Value": value_str}
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")):
if path.stem.endswith("_Settings"):
continue
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
platform = _extract_platform(policy, category)
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
base_row = {"Policy": policy_name, "Platform": platform}
# OMA-URI settings (Device Configuration custom profiles)
if isinstance(policy.get("omaSettings"), list):
for row in _expand_oma_settings(policy["omaSettings"], base_row):
row.update(assignment_cols)
rows.append(row)
# customSettings (App Configuration App, App Protection custom settings)
if isinstance(policy.get("customSettings"), list):
for row in _expand_custom_settings(policy["customSettings"], base_row):
row.update(assignment_cols)
rows.append(row)
for key, value in policy.items():
if key in _SKIP_KEYS or key in _SPECIAL_KEYS or value is None:
continue
if key in _B64_TEXT_KEYS:
if isinstance(value, str) and value:
value_str = _decode_b64_text(value)
else:
continue
elif 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 = {**base_row, "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] = []
# --- Dedicated structured processors ---
# Settings Catalog + Compliance Policies V2 (settingInstance)
rows.extend(process_settings_catalog(root, catalog, groups, include_assignments))
# Endpoint Security + Device Management Intents (companion _Settings.json, intent format)
rows.extend(process_intent_settings(root, groups, include_assignments))
# Administrative Templates (companion _Settings.json, definitionValues)
rows.extend(process_admx(root, groups, include_assignments))
# --- Flat processors ---
# Device Configuration (flat + OMA-URI expansion for custom profiles)
rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments,
"Device Configuration", "Device Configurations"))
# Compliance Policies V1
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
"Compliance Policies"))
# Scripts (scriptContent decoded from base64)
rows.extend(process_flat_category(root, "PowerShellScripts", groups, include_assignments,
"Scripts (PowerShell)"))
rows.extend(process_flat_category(root, "MacScripts", groups, include_assignments,
"Scripts (Shell)"))
rows.extend(process_flat_category(root, "MacCustomAttributes", groups, include_assignments,
"Custom Attributes"))
rows.extend(process_flat_category(root, "ComplianceScripts", groups, include_assignments,
"Compliance Scripts"))
rows.extend(process_flat_category(root, "DeviceHealthScripts", groups, include_assignments,
"Health Scripts"))
# App (customSettings expanded; payloadJson decoded)
rows.extend(process_flat_category(root, "AppProtection", groups, include_assignments,
"App Protection"))
rows.extend(process_flat_category(root, "AppConfigurationManagedApp", groups, include_assignments,
"App Configuration (App)"))
rows.extend(process_flat_category(root, "AppConfigurationManagedDevice", groups, include_assignments,
"App Configuration (Device)"))
# Enrollment
rows.extend(process_flat_category(root, "EnrollmentRestrictions", groups, include_assignments,
"Enrollment Restrictions"))
rows.extend(process_flat_category(root, "EnrollmentStatusPage", groups, include_assignments,
"Enrollment Status Page"))
rows.extend(process_flat_category(root, "AutoPilot", groups, include_assignments,
"Autopilot"))
# Updates
rows.extend(process_flat_category(root, "UpdatePolicies", groups, include_assignments,
"Update Policies"))
rows.extend(process_flat_category(root, "FeatureUpdates", groups, include_assignments,
"Feature Updates"))
rows.extend(process_flat_category(root, "WinFeatureUpdates", groups, include_assignments))
rows.extend(process_flat_category(root, "QualityUpdates", groups, include_assignments,
"Quality Updates"))
rows.extend(process_flat_category(root, "WinQualityUpdates", groups, include_assignments))
rows.extend(process_flat_category(root, "DriverUpdateProfiles", groups, include_assignments,
"Driver Update Profiles"))
rows.extend(process_flat_category(root, "WinDriverUpdatePolicies", groups, include_assignments))
# W365
rows.extend(process_flat_category(root, "W365ProvisioningPolicies", groups, include_assignments,
"W365 Provisioning Policies"))
rows.extend(process_flat_category(root, "W365UserSettings", groups, include_assignments,
"W365 User Settings"))
# Misc
rows.extend(process_flat_category(root, "AssignmentFilters", groups, include_assignments,
"Filters", "Assignment Filters"))
rows.extend(process_flat_category(root, "TermsAndConditions", groups, include_assignments,
"Terms and Conditions"))
rows.extend(process_flat_category(root, "Notifications", groups, include_assignments))
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()
+49
View File
@@ -0,0 +1,49 @@
<#
.SYNOPSIS
Headless Intune policy import wrapper for macOS/Linux/Windows.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[Parameter(Mandatory = $true)]
[string]$ImportPath,
[string]$SettingsFile,
[string]$BatchFile,
[string]$NameFilter = "",
[string]$NameSearchPattern = "",
[string]$NameReplacePattern = "",
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
[string]$ImportType = "alwaysImport",
[string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes),
[switch]$IncludeAssignments,
[switch]$IncludeScopeTags,
[switch]$ReplaceDependencyIds
)
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
Import-Module $modulePath -Force
Import-IntunePolicies @PSBoundParameters
+531
View File
@@ -0,0 +1,531 @@
<#
.SYNOPSIS
One-time setup helper for IntuneManagement headless authentication.
.DESCRIPTION
Creates a Microsoft Entra app registration (or reuses an existing one),
adds required Microsoft Graph permissions, creates a client secret, and
stores credentials securely:
- TenantId and AppId are saved to the JSON settings file.
- Client secret is saved to the macOS Keychain (default on macOS).
Requires: Microsoft.Graph.Authentication, Microsoft.Graph.Applications
Install if missing: Install-Module Microsoft.Graph -Scope CurrentUser
.PARAMETER TenantId
The Microsoft Entra tenant ID (GUID). If omitted, the script reads from
existing settings or prompts interactively.
.PARAMETER DisplayName
The display name for the app registration. Default: IntuneManagement-<current user name>.
.PARAMETER SettingsFile
Path to the JSON settings file. If omitted, defaults to the macOS_IntuneManagement
settings folder (~/Library/Application Support/macOS_IntuneManagement/Settings.json).
.PARAMETER Force
Recreate the app registration and secret even if existing credentials are found.
.PARAMETER Delete
Remove the saved tenant credentials from the local settings file (and macOS Keychain if applicable).
Does not delete the app registration in Entra ID.
.PARAMETER DeleteApp
Remove the app registration from the Entra tenant and clean up local credentials.
Requires the same Microsoft Graph permissions as initialization.
.PARAMETER RotateSecret
Create a new client secret for the existing app registration, remove the old
IntuneManagementSecret credential, and update local storage. Does not recreate
the app registration or re-grant admin consent.
.PARAMETER SecretExpiryYears
Lifetime of the created client secret in years (1-5). Default: 1.
#>
[CmdletBinding()]
param(
[string]$TenantId,
[string]$DisplayName = "IntuneManagement-$([Environment]::UserName)",
[string]$SettingsFile,
[switch]$Force,
[switch]$Delete,
[switch]$DeleteApp,
[switch]$RotateSecret,
[ValidateRange(1,5)]
[int]$SecretExpiryYears = 1
)
$ErrorActionPreference = "Stop"
#region Helper: settings file
$coreModule = Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1"
if (-not (Test-Path $coreModule))
{
throw "Could not find Core.psm1 at $coreModule"
}
Import-Module $coreModule -Force -Global
if (-not $SettingsFile)
{
$dataFolder = Get-CloudApiDataFolder
$SettingsFile = Join-Path $dataFolder "Settings.json"
}
$global:JSonSettingFile = $SettingsFile
Initialize-JsonSettings
function Save-AuthSetting
{
param($Key, $Value, [string]$SubPath = "")
Save-Setting -SubPath $SubPath -Key $Key -Value $Value
}
function Get-AuthSetting
{
param($Key, [string]$SubPath = "", $DefaultValue = $null)
Get-Setting -SubPath $SubPath -Key $Key -DefaultValue $DefaultValue
}
function Remove-LocalAuthSettings
{
param([string]$TenantId, [string]$AppId)
if ($global:JsonSettingsObj)
{
if ($global:JsonSettingsObj.ContainsKey($TenantId))
{
$global:JsonSettingsObj.Remove($TenantId) | Out-Null
Write-Host "Removed tenant settings for $TenantId from $SettingsFile" -ForegroundColor Green
}
if ($global:JsonSettingsObj["TenantId"] -eq $TenantId)
{
$global:JsonSettingsObj.Remove("TenantId") | Out-Null
Write-Host "Removed default TenantId from $SettingsFile" -ForegroundColor Green
}
$global:JsonSettingsObj | ConvertTo-Json -Depth 30 | Out-File -LiteralPath $global:JSonSettingFile -Force -Encoding utf8
}
if ($AppId)
{
if ($IsMacOS)
{
$null = security delete-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" 2>$null
Write-Host "Removed client secret for AppId $AppId from macOS Keychain" -ForegroundColor Green
}
else
{
Write-Host "Client secret was stored in $SettingsFile and has been removed along with the tenant node." -ForegroundColor Green
}
}
else
{
Write-Warning "No saved credentials found for tenant $TenantId."
}
}
#endregion
#region Determine TenantId
if (-not $TenantId)
{
$TenantId = Get-AuthSetting -Key "TenantId"
if (-not $TenantId)
{
$TenantId = Read-Host "Enter your Microsoft Entra Tenant ID (GUID)"
}
}
if (-not $TenantId)
{
throw "TenantId is required."
}
#endregion
#region Delete saved credentials
if ($Delete)
{
$appIdToClean = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId"
Remove-LocalAuthSettings -TenantId $TenantId -AppId $appIdToClean
return
}
#endregion
#region Rotate secret (no app recreation)
if ($RotateSecret)
{
$existingAppId = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId"
if (-not $existingAppId)
{
throw "No saved AppId found for tenant $TenantId. Run without -RotateSecret to set up first."
}
$requiredModulesRotate = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Applications")
foreach ($mod in $requiredModulesRotate)
{
if (-not (Get-Module $mod -ListAvailable))
{
throw "Module '$mod' is not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser"
}
}
Import-Module Microsoft.Graph.Authentication -Force
Import-Module Microsoft.Graph.Applications -Force
Write-Host ""
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph -Scopes "Application.ReadWrite.All" -NoWelcome
$appObj = Get-MgApplication -Filter "appId eq '$existingAppId'" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $appObj)
{
throw "App registration $existingAppId not found in tenant $TenantId."
}
# Remove existing IntuneManagementSecret credentials
$oldCreds = $appObj.PasswordCredentials | Where-Object { $_.DisplayName -eq "IntuneManagementSecret" }
foreach ($cred in $oldCreds)
{
Write-Host "Removing old secret (KeyId: $($cred.KeyId))..." -ForegroundColor Yellow
Remove-MgApplicationPassword -ApplicationId $appObj.Id -KeyId $cred.KeyId
}
# Create new secret
Write-Host "Creating new client secret..." -ForegroundColor Cyan
$newCred = @{
displayName = "IntuneManagementSecret"
endDateTime = (Get-Date).AddYears($SecretExpiryYears)
}
$newSecret = Add-MgApplicationPassword -ApplicationId $appObj.Id -PasswordCredential $newCred
# Store new secret
if ($IsMacOS)
{
$null = security add-generic-password -a "IntuneManagement" -s "IntuneMgmt-$existingAppId" -w "$($newSecret.SecretText)" -U 2>$null
Write-Host "New secret stored in macOS Keychain." -ForegroundColor Green
}
else
{
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppSecret" -Value $newSecret.SecretText
Write-Host "New secret stored in $SettingsFile." -ForegroundColor Green
}
Write-Host "Secret rotated. Expiry: $((Get-Date).AddYears($SecretExpiryYears).ToString('yyyy-MM-dd'))" -ForegroundColor Green
Disconnect-MgGraph | Out-Null
return
}
#endregion
#region Microsoft Graph modules
$requiredModules = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Applications")
foreach ($mod in $requiredModules)
{
if (-not (Get-Module $mod -ListAvailable))
{
throw "Module '$mod' is not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser"
}
}
Import-Module Microsoft.Graph.Authentication -Force
Import-Module Microsoft.Graph.Applications -Force
#endregion
#region Connect to Graph
Write-Host ""
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
Write-Host "A browser window will open for authentication." -ForegroundColor Cyan
Connect-MgGraph -Scopes "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All", "Organization.Read.All" -NoWelcome
#endregion
#region Resolve authenticated user for app naming
if (-not $PSBoundParameters.ContainsKey('DisplayName'))
{
try
{
$ctx = Get-MgContext -ErrorAction Stop
if ($ctx -and $ctx.Account)
{
$DisplayName = "IntuneManagement-$($ctx.Account)"
Write-Host "Using app display name: $DisplayName" -ForegroundColor DarkGray
}
}
catch { }
}
#endregion
#region Cache tenant name
try
{
$org = Get-MgOrganization -ErrorAction Stop
if ($org -and $org.DisplayName)
{
Save-AuthSetting -SubPath $TenantId -Key "TenantName" -Value $org.DisplayName
Write-Host "Cached tenant name: $($org.DisplayName)" -ForegroundColor Green
}
}
catch
{
Write-Warning "Failed to cache tenant name: $($_.Exception.Message)"
}
#endregion
#region Delete app registration and local credentials
if ($DeleteApp)
{
$appIdToClean = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId"
if ($appIdToClean)
{
$appToDelete = Get-MgApplication -Filter "appId eq '$appIdToClean'" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($appToDelete)
{
Remove-MgApplication -ApplicationId $appToDelete.Id
Write-Host "Deleted app registration $($appToDelete.DisplayName) ($appIdToClean) from tenant $TenantId" -ForegroundColor Green
}
else
{
Write-Warning "App registration $appIdToClean not found in tenant $TenantId."
}
}
else
{
Write-Warning "No AppId found in local settings for tenant $TenantId."
}
Remove-LocalAuthSettings -TenantId $TenantId -AppId $appIdToClean
Disconnect-MgGraph | Out-Null
return
}
#endregion
#region Check for existing credentials
$existingAppId = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId"
if ($existingAppId -and -not $Force)
{
$hasSecret = $false
if ($IsMacOS)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$existingAppId" -w 2>$null
if ($keychainSecret) { $hasSecret = $true }
}
catch { }
}
else
{
$plainSecret = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppSecret"
if ($plainSecret) { $hasSecret = $true }
}
if ($hasSecret)
{
Write-Host ""
Write-Host "Existing credentials already configured for tenant $TenantId (AppId: $existingAppId)." -ForegroundColor Green
Write-Host "Use -Force to recreate the app registration and secret." -ForegroundColor Yellow
Write-Host ""
return
}
}
#endregion
#region App registration
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
if (-not $graphSp)
{
throw "Could not retrieve Microsoft Graph service principal."
}
$requiredRoles = @(
"DeviceManagementApps.ReadWrite.All",
"DeviceManagementConfiguration.ReadWrite.All",
"DeviceManagementManagedDevices.ReadWrite.All",
"DeviceManagementScripts.ReadWrite.All",
"DeviceManagementServiceConfig.ReadWrite.All",
"DeviceManagementRBAC.ReadWrite.All",
"Group.ReadWrite.All",
"Directory.Read.All",
"User.Read.All",
"Organization.Read.All",
"Policy.ReadWrite.ConditionalAccess",
"Agreement.ReadWrite.All",
"CloudPC.ReadWrite.All",
"Application.Read.All"
)
$resourceAccess = @()
foreach ($roleName in $requiredRoles)
{
$appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $roleName } | Select-Object -First 1
if (-not $appRole)
{
Write-Warning "Could not find app role: $roleName"
continue
}
$resourceAccess += @{ id = $appRole.Id; type = "Role" }
}
$app = $null
$updatedPermissions = $false
if (-not $Force)
{
$existingApps = Get-MgApplication -Filter "displayName eq '$DisplayName'" -All
if ($existingApps)
{
$app = $existingApps | Select-Object -First 1
Write-Host "Reusing existing app registration: $($app.DisplayName) ($($app.AppId))" -ForegroundColor Yellow
# Check for missing permissions and patch if needed
$existingRra = $app.RequiredResourceAccess | Where-Object { $_.resourceAppId -eq "00000003-0000-0000-c000-000000000000" }
$existingIds = @()
if($existingRra -and $existingRra.resourceAccess)
{
$existingIds = $existingRra.resourceAccess | Select-Object -ExpandProperty id
}
$missingAccess = $resourceAccess | Where-Object { $_.id -notin $existingIds }
if($missingAccess)
{
Write-Host "Adding missing Graph API permissions to existing app..." -ForegroundColor Cyan
$newRra = @(@{
resourceAppId = "00000003-0000-0000-c000-000000000000"
resourceAccess = @($existingIds | ForEach-Object { @{ id = $_; type = "Role" } }) + $missingAccess
})
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $newRra
$updatedPermissions = $true
# Refresh app object
$app = Get-MgApplication -ApplicationId $app.Id
}
}
}
if (-not $app)
{
Write-Host "Creating new app registration: $DisplayName" -ForegroundColor Cyan
$appParams = @{
DisplayName = $DisplayName
SignInAudience = "AzureADMyOrg"
RequiredResourceAccess = @(@{
resourceAppId = "00000003-0000-0000-c000-000000000000"
resourceAccess = $resourceAccess
})
}
$app = New-MgApplication @appParams
}
#endregion
#region Service Principal & Admin Consent
$sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" -ErrorAction SilentlyContinue
if (-not $sp)
{
Write-Host "Creating service principal for the app..." -ForegroundColor Cyan
$sp = New-MgServicePrincipal -AppId $app.AppId
}
$consentGranted = $false
if ($sp)
{
Write-Host "Granting admin consent for Microsoft Graph permissions..." -ForegroundColor Cyan
$requiredAppRoles = $app.RequiredResourceAccess[0].resourceAccess
foreach ($ra in $requiredAppRoles)
{
$existing = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
Where-Object { $_.AppRoleId -eq $ra.id }
if (-not $existing)
{
try
{
New-MgServicePrincipalAppRoleAssignment `
-ServicePrincipalId $sp.Id `
-PrincipalId $sp.Id `
-ResourceId $graphSp.Id `
-AppRoleId $ra.id | Out-Null
$consentGranted = $true
}
catch
{
Write-Warning "Failed to grant consent for role $($ra.id): $($_.Exception.Message)"
}
}
else
{
$consentGranted = $true
}
}
}
#endregion
#region Client Secret
Write-Host "Creating client secret..." -ForegroundColor Cyan
$passwordCred = @{
displayName = "IntuneManagementSecret"
endDateTime = (Get-Date).AddYears($SecretExpiryYears)
}
$secret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred
#endregion
#region Save settings
Write-Host "Saving settings to $SettingsFile ..." -ForegroundColor Cyan
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId" -Value $app.AppId
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppLogin" -Value $true
Save-AuthSetting -Key "TenantId" -Value $TenantId
Save-AuthSetting -SubPath "EndpointManager" -Key "EMAzureApp" -Value $app.AppId
if ($IsMacOS)
{
Write-Host "Storing client secret in macOS Keychain..." -ForegroundColor Cyan
$null = security add-generic-password -a "IntuneManagement" -s "IntuneMgmt-$($app.AppId)" -w "$($secret.SecretText)" -U 2>$null
}
else
{
Write-Warning "Not running on macOS. Storing client secret in the settings file (less secure)."
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppSecret" -Value $secret.SecretText
}
#endregion
#region Summary
Write-Host ""
Write-Host "=============================================================" -ForegroundColor Green
Write-Host "Authentication setup complete!" -ForegroundColor Green
Write-Host "=============================================================" -ForegroundColor Green
Write-Host "TenantId : $TenantId"
Write-Host "AppId : $($app.AppId)"
Write-Host "Settings : $SettingsFile"
if ($IsMacOS)
{
Write-Host "Secret : <stored in macOS Keychain>"
}
else
{
Write-Host "Secret : <stored in $SettingsFile>"
}
Write-Host "=============================================================" -ForegroundColor Green
if (-not $consentGranted)
{
Write-Host "IMPORTANT: Admin consent could not be granted automatically." -ForegroundColor Yellow
Write-Host " Go to the Entra portal > API Permissions and click" -ForegroundColor Yellow
Write-Host " 'Grant admin consent for <tenant>' before using" -ForegroundColor Yellow
Write-Host " the app for Export or Import." -ForegroundColor Yellow
Write-Host "=============================================================" -ForegroundColor Green
}
else
{
Write-Host "Admin consent granted successfully." -ForegroundColor Green
Write-Host "=============================================================" -ForegroundColor Green
}
Write-Host ""
Write-Host "You can now run exports without specifying -AppId or -Secret:" -ForegroundColor Cyan
Write-Host " ./Scripts/Export-Policies.ps1 -TenantId `"$TenantId`" -ExportPath `"/tmp/intune-export`" -IncludeAssignments" -ForegroundColor Cyan
Write-Host ""
Disconnect-MgGraph | Out-Null
#endregion
+165
View File
@@ -0,0 +1,165 @@
#requires -Version 7.0
<#
.SYNOPSIS
Deploy an Intune or CIS M365 baseline to multiple tenants from a CSV manifest.
.DESCRIPTION
Reads a CSV file with one row per tenant, invokes Deploy-IntuneBaseline.ps1 or
Deploy-CISM365Baseline.ps1 for each row, and aggregates all per-tenant reports
into a single combined CSV summary.
CSV columns (Deploy-IntuneBaseline mode):
TenantId, BaselinePath, AppId, Secret, Certificate, AuthMode, ConflictResolution, WhatIf
CSV columns (Deploy-CISM365Baseline mode):
TenantId, BaselinePath, AppId, Secret, Certificate, AuthMode, Mode, Workloads, WhatIf
All columns except TenantId and BaselinePath are optional.
.PARAMETER CsvPath
Path to the CSV manifest file.
.PARAMETER ScriptMode
Which deployment script to invoke per tenant: 'Intune' or 'CIS'. Default: Intune.
.PARAMETER OutputDir
Directory for per-tenant reports and the combined summary. Default: same directory as CsvPath.
.PARAMETER WhatIf
Propagates WhatIf to every tenant run, overriding the CSV column.
.EXAMPLE
./Scripts/Invoke-BaselineBatch.ps1 -CsvPath ./tenants.csv -ScriptMode Intune
.EXAMPLE
./Scripts/Invoke-BaselineBatch.ps1 -CsvPath ./tenants.csv -ScriptMode CIS -WhatIf
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$CsvPath,
[ValidateSet("Intune","CIS")]
[string]$ScriptMode = "Intune",
[string]$OutputDir,
[switch]$WhatIf
)
$ErrorActionPreference = "Stop"
$csvResolved = Resolve-Path $CsvPath | Select-Object -ExpandProperty Path
if (-not (Test-Path $csvResolved)) { throw "CSV not found: $CsvPath" }
$rows = Import-Csv -Path $csvResolved
if (-not $rows -or $rows.Count -eq 0) { throw "CSV is empty: $CsvPath" }
$scriptDir = Split-Path -Parent $PSScriptRoot
$intuneScript = Join-Path $scriptDir "Scripts/Deploy-IntuneBaseline.ps1"
$cisScript = Join-Path $scriptDir "Scripts/Deploy-CISM365Baseline.ps1"
$targetScript = if ($ScriptMode -eq "CIS") { $cisScript } else { $intuneScript }
if (-not (Test-Path $targetScript)) { throw "Deployment script not found: $targetScript" }
$resolvedOutputDir = if ($OutputDir) { $OutputDir } else { Split-Path -Parent $csvResolved }
if (-not (Test-Path $resolvedOutputDir)) { New-Item -ItemType Directory -Path $resolvedOutputDir | Out-Null }
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$batchSummary = [System.Collections.Generic.List[PSCustomObject]]::new()
$rowIndex = 0
foreach ($row in $rows)
{
$rowIndex++
$tenantId = $row.TenantId?.Trim()
$baselinePath = $row.BaselinePath?.Trim()
if ([string]::IsNullOrWhiteSpace($tenantId) -or [string]::IsNullOrWhiteSpace($baselinePath))
{
Write-Warning "Row $rowIndex skipped: TenantId or BaselinePath is empty."
$batchSummary.Add([PSCustomObject]@{
Row = $rowIndex
TenantId = $tenantId
Baseline = $baselinePath
Outcome = 'Skipped-InvalidRow'
ReportPath = $null
Error = 'TenantId or BaselinePath empty'
})
continue
}
$tenantReportPath = Join-Path $resolvedOutputDir "${tenantId}_${ts}.csv"
Write-Host ""
Write-Host "======================================================" -ForegroundColor Cyan
Write-Host "Tenant $rowIndex/$($rows.Count): $tenantId" -ForegroundColor Cyan
Write-Host "Baseline : $baselinePath" -ForegroundColor Cyan
Write-Host "======================================================" -ForegroundColor Cyan
$params = @{
TenantId = $tenantId
BaselinePath = $baselinePath
}
if ($row.PSObject.Properties['AppId'] -and $row.AppId) { $params.AppId = $row.AppId }
if ($row.PSObject.Properties['Secret'] -and $row.Secret) { $params.Secret = $row.Secret }
if ($row.PSObject.Properties['Certificate'] -and $row.Certificate) { $params.Certificate = $row.Certificate }
if ($row.PSObject.Properties['AuthMode'] -and $row.AuthMode) { $params.AuthMode = $row.AuthMode }
if ($WhatIf -or ($row.PSObject.Properties['WhatIf'] -and $row.WhatIf -match '(?i)^true|yes|1$'))
{
$params.WhatIf = $true
}
if ($ScriptMode -eq "Intune")
{
if ($row.PSObject.Properties['ConflictResolution'] -and $row.ConflictResolution) { $params.ConflictResolution = $row.ConflictResolution }
$params.ReportPath = $tenantReportPath
}
else
{
if ($row.PSObject.Properties['Mode'] -and $row.Mode) { $params.Mode = $row.Mode }
if ($row.PSObject.Properties['Workloads'] -and $row.Workloads)
{
$params.Workloads = $row.Workloads -split '\s*[,;]\s*'
}
}
$outcome = 'Success'
$errorMsg = $null
try
{
& $targetScript @params
}
catch
{
$outcome = 'Failed'
$errorMsg = $_.Exception.Message
Write-Warning "Tenant $tenantId failed: $errorMsg"
}
$batchSummary.Add([PSCustomObject]@{
Row = $rowIndex
TenantId = $tenantId
Baseline = $baselinePath
Outcome = $outcome
ReportPath = if ($ScriptMode -eq "Intune" -and (Test-Path $tenantReportPath)) { $tenantReportPath } else { $null }
Error = $errorMsg
})
}
$summaryPath = Join-Path $resolvedOutputDir "BatchSummary_${ts}.csv"
$batchSummary | Export-Csv -Path $summaryPath -NoTypeInformation -Force
Write-Host ""
Write-Host "======================================================" -ForegroundColor Green
Write-Host "Batch complete. $($rows.Count) tenant(s) processed." -ForegroundColor Green
Write-Host "Summary: $summaryPath" -ForegroundColor Green
$failed = $batchSummary | Where-Object { $_.Outcome -ne 'Success' }
if ($failed)
{
Write-Host "Failed tenants:" -ForegroundColor Red
$failed | ForEach-Object { Write-Host " $($_.TenantId): $($_.Error)" -ForegroundColor Red }
}
Write-Host "======================================================" -ForegroundColor Green
@@ -0,0 +1,728 @@
<#PSScriptInfo
.VERSION 1.9.0
.GUID 6c861af7-d12e-4ea2-b5dc-56fee16e0107
.AUTHOR Nicola Suter
.TAGS ConditionalAccess, AzureAD, Identity
.PROJECTURI https://git.cqre.net/vibecoding/CAExporter
.ICONURI https://raw.githubusercontent.com/microsoftgraph/g-raph/master/g-raph.png
.DESCRIPTION This script documents Azure AD Conditional Access Policies using the latest Microsoft.Graph PowerShell module.
.SYNOPSIS This script retrieves all Conditional Access Policies and translates Azure AD Object IDs to display names for users, groups, directory roles, locations...
.EXAMPLE
Connect-MgGraph -Scopes "Application.Read.All", "Group.Read.All", "Policy.Read.All", "RoleManagement.Read.Directory", "User.Read.All"
& .\Invoke-ConditionalAccessDocumentation.ps1
Generates the documentation and exports the csv to the script directory.
.NOTES
Author: Nicola Suter
Creation Date: 31.01.2022
Updated: 15.09.2025
#>
param(
[switch]$ExportExcel,
[string]$ExcelPath
)
# NOTE: Module requirements are handled programmatically below to allow auto-install.
$RequiredGraphVersion = '2.30.0'
$RequiredGraphModules = @(
'Microsoft.Graph.Authentication',
'Microsoft.Graph.Applications',
'Microsoft.Graph.Identity.SignIns',
'Microsoft.Graph.Groups',
'Microsoft.Graph.DirectoryObjects',
'Microsoft.Graph.Identity.DirectoryManagement',
'Microsoft.Graph.Identity.Governance'
)
function Ensure-NuGetProvider {
try {
if (-not (Get-PackageProvider -ListAvailable -Name 'NuGet' -ErrorAction SilentlyContinue)) {
Install-PackageProvider -Name 'NuGet' -Force -Scope CurrentUser | Out-Null
}
} catch { Write-Warning "NuGet provider installation failed: $($_.Exception.Message)" }
}
function Ensure-PSGalleryTrusted {
try {
$repo = Get-PSRepository -Name 'PSGallery' -ErrorAction Stop
if ($repo.InstallationPolicy -ne 'Trusted') {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted -ErrorAction Stop
}
} catch { Write-Warning "Failed to set PSGallery trusted: $($_.Exception.Message)" }
}
function Ensure-Module {
param(
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Version
)
$hasVersion = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue |
Where-Object { $_.Version -eq [version]$Version }
if (-not $hasVersion) {
Write-Verbose "Installing $Name $Version for current user..."
Ensure-NuGetProvider
Ensure-PSGalleryTrusted
try {
Install-Module -Name $Name -RequiredVersion $Version -Scope CurrentUser -Force -ErrorAction Stop
} catch {
Write-Warning "Exact version $Version not available for $Name. Installing latest available version."
Install-Module -Name $Name -Scope CurrentUser -Force -ErrorAction Stop
}
}
Import-Module -Name $Name -ErrorAction Stop | Out-Null
}
foreach ($m in $RequiredGraphModules) { Ensure-Module -Name $m -Version $RequiredGraphVersion }
# If Excel export requested, ensure ImportExcel module is loaded/installed
if ($ExportExcel) {
try {
Import-Module ImportExcel -ErrorAction Stop | Out-Null
} catch {
Write-Host 'Installing ImportExcel module for Excel export...' -ForegroundColor Cyan
Ensure-PSGalleryTrusted
Install-Module -Name ImportExcel -Scope CurrentUser -Force -ErrorAction Stop
Import-Module ImportExcel -ErrorAction Stop | Out-Null
}
# --- Helpers for older ImportExcel versions ---
if (-not (Get-Command New-WorksheetName -ErrorAction SilentlyContinue)) {
function New-WorksheetName {
param([string]$Name)
if (-not $Name) { return 'Sheet' }
$san = ($Name -replace '[\\/:*?\[\]]','_')
if ($san.Length -gt 31) { $san = $san.Substring(0,31) }
if ($san -match '^\d+$') { $san = "_$san" }
return $san
}
}
if (-not (Get-Command Set-Cell -ErrorAction SilentlyContinue)) {
function Set-Cell {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$WorksheetName,
[Parameter(Mandatory)][int]$Row,
[Parameter(Mandatory)][int]$Column,
[string]$Value,
[string]$Hyperlink,
[switch]$Formula
)
# Fallback using EPPlus via ImportExcel helper cmdlets
$pkg = Open-ExcelPackage -Path $Path
try {
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if (-not $ws) { $ws = Add-WorkSheet -ExcelPackage $pkg -WorksheetName $WorksheetName }
$cell = $ws.Cells[$Row,$Column]
if ($Formula) {
try { $cell.Clear() } catch { }
if ($Value -and $Value.StartsWith('=')) { $cell.Formula = $Value.Substring(1) } else { $cell.Formula = $Value }
} else {
$cell.Value = $Value
}
if ($Hyperlink) { $cell.Hyperlink = $Hyperlink }
}
finally {
try { $pkg.Workbook.CalcMode = [OfficeOpenXml.ExcelCalcMode]::Automatic } catch { }
try { $ws.Calculate() } catch { }
try { $pkg.Save() } catch { }
try { $pkg.Dispose() } catch { }
}
}
}
function Try-AddConditionalFormatting {
param(
[string]$Path,
[string]$WorksheetName,
[string]$Range,
[string]$RuleType,
[string]$ConditionValue,
[string]$ForegroundColor,
[string]$BackgroundColor
)
try {
Add-ConditionalFormatting -Path $Path -WorksheetName $WorksheetName -Range $Range -RuleType $RuleType -ConditionValue $ConditionValue -ForegroundColor $ForegroundColor -BackgroundColor $BackgroundColor
} catch {
try {
$pkg = Open-ExcelPackage -Path $Path
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if ($ws) {
$cf = $ws.ConditionalFormatting.AddContainsText($Range)
$cf.Text = $ConditionValue
if ($BackgroundColor) { $cf.Style.Fill.BackgroundColor.SetColor([System.Drawing.Color]::$BackgroundColor) }
}
$pkg.Save(); $pkg.Dispose()
} catch { }
}
}
function Try-SetColumnWidth {
param(
[string]$Path,
[string]$WorksheetName,
[int]$Column,
[double]$Width
)
try {
Set-Column -Path $Path -WorksheetName $WorksheetName -Column $Column -Width $Width
} catch {
try {
$pkg = Open-ExcelPackage -Path $Path
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if ($ws) { $ws.Column($Column).Width = $Width }
$pkg.Save(); $pkg.Dispose()
} catch { }
}
}
function Try-SetFormat {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$WorksheetName,
[Parameter(Mandatory)][string]$Range,
[switch]$Bold,
[int]$FontSize,
[string]$BackgroundColor
)
$ok = $false
try {
Set-Format -Path $Path -WorksheetName $WorksheetName -Range $Range -Bold:$Bold -FontSize $FontSize -BackgroundColor $BackgroundColor
$ok = $true
} catch {}
if (-not $ok) {
try {
Set-Format -Address $Range -WorkSheetname $WorksheetName -Bold:$Bold -FontSize $FontSize -BackgroundColor $BackgroundColor -PassThru | Export-Excel -Path $Path -WorksheetName $WorksheetName -Append
$ok = $true
} catch {}
}
if (-not $ok) {
try {
$pkg = Open-ExcelPackage -Path $Path
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if ($ws) {
$addr = [OfficeOpenXml.ExcelAddress]::new($Range)
$cells = $ws.Cells[$addr.Address]
if ($Bold) { $cells.Style.Font.Bold = $true }
if ($FontSize -gt 0) { $cells.Style.Font.Size = $FontSize }
if ($BackgroundColor) { $cells.Style.Fill.PatternType = [OfficeOpenXml.Style.ExcelFillStyle]::Solid; $cells.Style.Fill.BackgroundColor.SetColor([System.Drawing.Color]::$BackgroundColor) }
}
$pkg.Save(); $pkg.Dispose()
} catch { }
}
}
function Add-PolicyNamesNamedRange {
param([Parameter(Mandatory)][string]$Path)
$pkg = Open-ExcelPackage -Path $Path
try {
$ws = $pkg.Workbook.Worksheets['Master']
if (-not $ws) { return }
$lastCol = $ws.Dimension.End.Column
$nameCol = $null
for ($c=1; $c -le $lastCol; $c++) {
if (($ws.Cells[1,$c].Text) -eq 'Name') { $nameCol = $c; break }
}
if ($null -eq $nameCol) { return }
$lastRow = $ws.Dimension.End.Row
if ($lastRow -lt 2) { return }
$rangeAddress = [OfficeOpenXml.ExcelAddress]::new(2, $nameCol, $lastRow, $nameCol)
$existing = $pkg.Workbook.Names['PolicyNames']
if ($existing) { $pkg.Workbook.Names.Remove($existing) | Out-Null }
[void]$pkg.Workbook.Names.Add('PolicyNames', $ws.Cells[$rangeAddress.Address])
$pkg.Save()
} finally { try { $pkg.Dispose() } catch { } }
}
function Add-PolicyNameValidation {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$WorksheetName
)
$pkg = Open-ExcelPackage -Path $Path
try {
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
if (-not $ws) { return }
$dv = $ws.DataValidations.AddListValidation('B1')
$dv.Formula.ExcelFormula = 'PolicyNames'
$dv.ShowErrorMessage = $true
$dv.ErrorTitle = 'Invalid selection'
$dv.Error = 'Please pick a policy name from the list.'
$pkg.Save()
} finally { try { $pkg.Dispose() } catch { } }
}
}
# --- Ensure connection to Microsoft Graph with required scopes ---
$RequiredMgScopes = @(
'Application.Read.All',
'Group.Read.All',
'Policy.Read.All',
'RoleManagement.Read.Directory',
'User.Read.All',
'NetworkAccessPolicy.Read.All', # optional: for Global Secure Access profile names
'Agreement.Read.All' # optional: for Terms of Use display names
)
function Ensure-MgConnection {
try { $ctx = Get-MgContext -ErrorAction Stop } catch { $ctx = $null }
$needConnect = $true
if ($ctx) {
$currentScopes = @($ctx.Scopes)
if ($currentScopes -and ($RequiredMgScopes | Where-Object { $currentScopes -contains $_ }).Count -eq $RequiredMgScopes.Count) {
$needConnect = $false
}
}
if ($needConnect) {
Write-Host 'Connecting to Microsoft Graph...' -ForegroundColor Cyan
Connect-MgGraph -Scopes $RequiredMgScopes -NoWelcome
}
}
Ensure-MgConnection
function Test-Guid {
[Cmdletbinding()]
[OutputType([bool])]
param
(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
[AllowEmptyString()]
[string]$InputObject
)
process {
return [guid]::TryParse($InputObject, $([ref][guid]::Empty))
}
}
function Resolve-MgObject {
[Cmdletbinding()]
[OutputType([string])]
param
(
[Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
[AllowEmptyString()]
[string]$InputObject
)
process {
if (Test-Guid -InputObject $InputObject) {
try {
if ($displayNameCache.ContainsKey($InputObject)) {
Write-Debug "Cached display name for `"$InputObject`""
return $displayNameCache[$InputObject]
} else {
$directoryObject = Get-MgDirectoryObject -DirectoryObjectId $InputObject -ErrorAction Stop
$displayName = $directoryObject.AdditionalProperties['displayName']
$displayNameCache[$InputObject] = $displayName
return $displayName
}
} catch {
Write-Warning "Unable to resolve directory object with ID $InputObject, might have been deleted!"
}
}
return $InputObject
}
}
# Add GetOrDefault to hashtables
$etd = @{
TypeName = 'System.Collections.Hashtable'
MemberType = 'Scriptmethod'
MemberName = 'GetOrDefault'
Value = {
param(
$key,
$defaultValue
)
if (-not [string]::IsNullOrEmpty($key)) {
if ($this.ContainsKey($key)) {
if ($this[$key].DisplayName) {
return $this[$key].DisplayName
} else {
return $this[$key]
}
} else {
return $defaultValue
}
}
}
}
Update-TypeData @etd -Force
Write-Progress -PercentComplete -1 -Activity 'Fetching conditional access policies and related data from Graph API'
try {
if (-not (Get-MgContext)) { Write-Warning "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes \"Application.Read.All\",\"Group.Read.All\",\"Policy.Read.All\",\"RoleManagement.Read.Directory\",\"User.Read.All\",\"NetworkAccessPolicy.Read.All\",\"Agreement.Read.All\"" }
} catch { }
# Get Conditional Access Policies
$conditionalAccessPolicies = Get-MgIdentityConditionalAccessPolicy -ExpandProperty '*' -All -ErrorAction Stop
# Get Conditional Access Named / Trusted Locations
$namedLocations = Get-MgIdentityConditionalAccessNamedLocation -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
if (-not $namedLocations) { $namedLocations = @{} }
# Get Azure AD Directory Role Templates
try {
$directoryRoleTemplates = Get-MgDirectoryRoleTemplate -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
} catch {
Write-Warning "Directory role templates could not be retrieved (module missing or insufficient permissions). Role names will not be resolved."
$directoryRoleTemplates = @{}
}
# Service Principals
$servicePrincipals = Get-MgServicePrincipal -All -ErrorAction Stop | Group-Object -Property AppId -AsHashTable
# Terms of Use Agreements
try {
$termsOfUseAgreements = Get-MgIdentityGovernanceTermsOfUseAgreement -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
} catch {
Write-Warning "Terms of Use agreements could not be retrieved or permission missing (Agreement.Read.All)."
$termsOfUseAgreements = @{}
}
# Authentication context class references
try {
$authContextClassReferences = Get-MgIdentityConditionalAccessAuthenticationContextClassReference -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
} catch {
Write-Warning "Authentication context class references could not be retrieved. Context names will not be resolved."
$authContextClassReferences = @{}
}
# GSA network filtering profiles
try {
$networkFilteringProfiles = Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/beta/networkAccess/filteringProfiles' -Method GET -OutputType PSObject -ErrorAction Stop |
Select-Object -ExpandProperty value |
Group-Object -Property id -AsHashTable
} catch {
Write-Warning "Global Secure Access filtering profiles not available or insufficient permission. Skipping."
$networkFilteringProfiles = @{}
}
# Init report
$documentation = [System.Collections.Generic.List[Object]]::new()
# Cache for resolved display names
$displayNameCache = @{}
# Process all Conditional Access Policies
foreach ($policy in $conditionalAccessPolicies) {
$currentIndex = $conditionalAccessPolicies.indexOf($policy) + 1
$progress = @{
Activity = 'Generating Conditional Access Documentation...'
PercentComplete = [Decimal]::Divide($currentIndex, $conditionalAccessPolicies.Count) * 100
CurrentOperation = "Processing Policy `"$($policy.DisplayName)`""
}
if ($currentIndex -eq $conditionalAccessPolicies.Count) { $progress.Add('Completed', $true) }
Write-Progress @progress
Write-Output "Processing policy `"$($policy.DisplayName)`""
try {
$includeUsers = @($policy.Conditions?.Users?.IncludeUsers) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$excludeUsers = @($policy.Conditions?.Users?.ExcludeUsers) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$includeGroups = @($policy.Conditions?.Users?.IncludeGroups) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$excludeGroups = @($policy.Conditions?.Users?.ExcludeGroups) | ForEach-Object { Resolve-MgObject -InputObject $_ }
$includeRoles = @($policy.Conditions?.Users?.IncludeRoles) | ForEach-Object { $directoryRoleTemplates.GetOrDefault($_, $_) }
$excludeRoles = @($policy.Conditions?.Users?.ExcludeRoles) | ForEach-Object { $directoryRoleTemplates.GetOrDefault($_, $_) }
$includeApps = @($policy.Conditions?.Applications?.IncludeApplications) | ForEach-Object { $servicePrincipals.GetOrDefault($_, $_) }
$excludeApps = @($policy.Conditions?.Applications?.ExcludeApplications) | ForEach-Object { $servicePrincipals.GetOrDefault($_, $_) }
$includeServicePrincipals = [System.Collections.Generic.List[Object]]::new()
$excludeServicePrincipals = [System.Collections.Generic.List[Object]]::new()
@($policy.Conditions?.ClientApplications?.IncludeServicePrincipals) | ForEach-Object { $includeServicePrincipals.Add($servicePrincipals.GetOrDefault($_, $_)) }
@($policy.Conditions?.ClientApplications?.ExcludeServicePrincipals) | ForEach-Object { $excludeServicePrincipals.Add($servicePrincipals.GetOrDefault($_, $_)) }
$includeAuthenticationContext = @($policy.Conditions?.Applications?.IncludeAuthenticationContextClassReferences) |
ForEach-Object { $authContextClassReferences.GetOrDefault($_, $_) }
$includeLocations = @($policy.Conditions?.Locations?.IncludeLocations) | ForEach-Object { $namedLocations.GetOrDefault($_, $_) }
$excludeLocations = @($policy.Conditions?.Locations?.ExcludeLocations) | ForEach-Object { $namedLocations.GetOrDefault($_, $_) }
$webFilteringProfile = $null
try {
$gsaProp = $policy.SessionControls?.AdditionalProperties?['globalSecureAccessFilteringProfile']
if ($gsaProp) {
$profileId = $gsaProp['profileId']
if ($profileId -and $networkFilteringProfiles.ContainsKey($profileId)) {
$webFilteringProfile = $networkFilteringProfiles[$profileId].name
}
}
} catch { }
$separator = "; "
$grantBuiltIn = @()
if ($policy.GrantControls?.BuiltInControls) { $grantBuiltIn += $policy.GrantControls.BuiltInControls }
if ($policy.GrantControls?.TermsOfUse) { $grantBuiltIn += 'termsOfUse' }
if ($policy.GrantControls?.AuthenticationStrength) { $grantBuiltIn += 'authenticationStrength' }
$grantControls = $grantBuiltIn | Where-Object { $_ -ne 'authenticationStrength' }
$authStrengthName = $policy.GrantControls?.AuthenticationStrength?.DisplayName
if ($authStrengthName) { $grantControls += 'authenticationStrength' }
$authStrengthAllowed = @($policy.GrantControls?.AuthenticationStrength?.AllowedCombinations) -join $separator
$signInFrequency = $null
if ($policy.SessionControls?.SignInFrequency?.Value) {
$signInFrequency = "{0} {1}" -f $policy.SessionControls.SignInFrequency.Value, $policy.SessionControls.SignInFrequency.Type
}
$secureSignInSession = $null
$ss = $policy.SessionControls?.AdditionalProperties?['secureSignInSession']
if ($ss) { $secureSignInSession = $ss.isEnabled }
$includeDeviceStates = @($policy.Conditions?.DeviceStates?.IncludeStates)
$excludeDeviceStates = @($policy.Conditions?.DeviceStates?.ExcludeStates)
$includeGuestsOrExternalUserTypes = $policy.Conditions?.Users?.IncludeGuestsOrExternalUsers?.guestOrExternalUserTypes
$includeGuestsOrExternalUserTenants = @($policy.Conditions?.Users?.IncludeGuestsOrExternalUsers?.externalTenants?.AdditionalProperties?['members'])
$authFlowsObj = $policy.Conditions?.AuthenticationFlows
$authenticationFlows = if ($authFlowsObj) {
$authFlowsObj.TransferMethods ?? $authFlowsObj.AdditionalProperties?['transferMethods']
}
$applicationsAdditional = $null
if ($policy.Conditions?.Applications?.AdditionalProperties) {
$applicationsAdditional = ($policy.Conditions.Applications.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$conditionsAdditional = $null
if ($policy.Conditions?.AdditionalProperties) {
$conditionsAdditional = ($policy.Conditions.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$termsOfUseNames = $null
if ($policy.GrantControls?.TermsOfUse) {
$termsOfUseNames = ($policy.GrantControls.TermsOfUse | ForEach-Object { $termsOfUseAgreements.GetOrDefault($_, $_) }) -join $separator
}
$grantControlsAdditional = $null
if ($policy.GrantControls?.AdditionalProperties) {
$grantControlsAdditional = ($policy.GrantControls.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$cloudAppSecurityMode = $policy.SessionControls?.CloudAppSecurity?.Mode
$sessionAdditional = $null
if ($policy.SessionControls?.AdditionalProperties) {
$sessionAdditional = ($policy.SessionControls.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
}
$documentation.Add(
[PSCustomObject]@{
Name = $policy.DisplayName
IncludeUsers = ($includeUsers -join $separator)
IncludeGroups = ($includeGroups -join $separator)
IncludeRoles = ($includeRoles -join $separator)
ExcludeUsers = ($excludeUsers -join $separator)
ExcludeGuestOrExternalUserTypes = $policy.Conditions?.Users?.ExcludeGuestsOrExternalUsers?.guestOrExternalUserTypes
ExcludeGuestOrExternalUserTenants = (@($policy.Conditions?.Users?.ExcludeGuestsOrExternalUsers?.externalTenants?.AdditionalProperties?['members']) -join $separator)
ExcludeGroups = ($excludeGroups -join $separator)
ExcludeRoles = ($excludeRoles -join $separator)
IncludeApps = ($includeApps -join $separator)
ExcludeApps = ($excludeApps -join $separator)
ApplicationFilterMode = $policy.Conditions?.Applications?.ApplicationFilter?.mode
ApplicationFilterRule = $policy.Conditions?.Applications?.ApplicationFilter?.rule
IncludeAuthenticationContext = ($includeAuthenticationContext -join $separator)
IncludeUserActions = (@($policy.Conditions?.Applications?.IncludeUserActions) -join $separator)
ClientAppTypes = (@($policy.Conditions?.ClientAppTypes) -join $separator)
IncludePlatforms = (@($policy.Conditions?.Platforms?.IncludePlatforms) -join $separator)
ExcludePlatforms = (@($policy.Conditions?.Platforms?.ExcludePlatforms) -join $separator)
IncludeLocations = ($includeLocations -join $separator)
ExcludeLocations = ($excludeLocations -join $separator)
DeviceFilterMode = $policy.Conditions?.Devices?.DeviceFilter?.Mode
DeviceFilterRule = $policy.Conditions?.Devices?.DeviceFilter?.Rule
SignInRiskLevels = (@($policy.Conditions?.SignInRiskLevels) -join $separator)
UserRiskLevels = (@($policy.Conditions?.UserRiskLevels) -join $separator)
ServicePrincipalRiskLevels = (@($policy.Conditions?.servicePrincipalRiskLevels) -join $separator)
IncludeDeviceStates = (@($includeDeviceStates) -join $separator)
ExcludeDeviceStates = (@($excludeDeviceStates) -join $separator)
IncludeGuestsOrExternalUserTypes = $includeGuestsOrExternalUserTypes
IncludeGuestOrExternalUserTenants = (@($includeGuestsOrExternalUserTenants) -join $separator)
AuthenticationFlows = $authenticationFlows
ApplicationsAdditional = $applicationsAdditional
ConditionsAdditional = $conditionsAdditional
IncludeServicePrincipals = ($includeServicePrincipals -join $separator)
ExcludeServicePrincipals = ($excludeServicePrincipals -join $separator)
ServicePrincipalFilterMode = $policy.Conditions?.ClientApplications?.ServicePrincipalFilter?.mode
ServicePrincipalFilter = $policy.Conditions?.ClientApplications?.ServicePrincipalFilter?.rule
GrantControls = ($grantControls -join $separator)
GrantControlsOperator = $policy.GrantControls?.Operator
AuthenticationStrength = $authStrengthName
AuthenticationStrengthAllowedCombinations = $authStrengthAllowed
TermsOfUseNames = $termsOfUseNames
GrantControlsAdditional = $grantControlsAdditional
ApplicationEnforcedRestrictions = $policy.SessionControls?.ApplicationEnforcedRestrictions?.IsEnabled
CloudAppSecurity = $policy.SessionControls?.CloudAppSecurity?.IsEnabled
CloudAppSecurityMode = $cloudAppSecurityMode
DisableResilienceDefaults = $policy.SessionControls?.DisableResilienceDefaults
PersistentBrowser = $policy.SessionControls?.PersistentBrowser?.Mode
SignInFrequency = $signInFrequency
SecureSignInSession = $secureSignInSession
GlobalSecureAccessFilteringProfile = $webFilteringProfile
SessionControlsAdditional = $sessionAdditional
State = $policy.State
}
)
} catch {
Write-Error $PSItem
}
}
# Build export path (script directory)
$exportPath = Join-Path $PSScriptRoot 'ConditionalAccessDocumentation.csv'
$CsvDelimiter = ';'
$exportParams = @{ Path = $exportPath; NoTypeInformation = $true; Delimiter = $CsvDelimiter; Encoding = 'utf8BOM' }
try {
$exportParams['UseQuotes'] = 'AsNeeded'
} catch { }
try {
$documentation | Export-Csv @exportParams
} catch {
Write-Warning "Export-Csv with UTF-8 BOM failed on this PowerShell version. Retrying with UTF-8 (no BOM)."
$exportParams['Encoding'] = 'utf8'
$documentation | Export-Csv @exportParams
}
Write-Output "Exported Documentation to '$($exportPath)'"
if ($ExportExcel) {
Write-Host 'Building Excel workbook...' -ForegroundColor Cyan
if (-not $ExcelPath) {
$ExcelPath = Join-Path $PSScriptRoot 'ConditionalAccessDocumentation.xlsx'
}
if (Test-Path $ExcelPath) { Remove-Item $ExcelPath -Force }
$documentation | Export-Excel -Path $ExcelPath -WorksheetName 'Master' -TableName 'Master' -TableStyle 'Medium9' -ClearSheet -FreezeTopRow -AutoFilter
$summary = $documentation | Select-Object Name, State
$summary | Export-Excel -Path $ExcelPath -WorksheetName 'Summary' -TableName 'Policies' -TableStyle 'Medium6' -ClearSheet -FreezeTopRow -AutoFilter
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'enabled' -ForegroundColor 'Black' -BackgroundColor 'LightGreen'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'disabled' -ForegroundColor 'Black' -BackgroundColor 'LightGray'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'reportOnly' -ForegroundColor 'Black' -BackgroundColor 'Khaki'
Add-PolicyNamesNamedRange -Path $ExcelPath
$sections = @(
@{ Title = 'General'; Fields = @(
@{ Label = 'Policy name'; Col = 'Name' },
@{ Label = 'State'; Col = 'State' }
)},
@{ Title = 'Users and groups'; Fields = @(
@{ Label = 'Include users'; Col = 'IncludeUsers' },
@{ Label = 'Include groups'; Col = 'IncludeGroups' },
@{ Label = 'Include roles'; Col = 'IncludeRoles' },
@{ Label = 'Exclude users'; Col = 'ExcludeUsers' },
@{ Label = 'Exclude groups'; Col = 'ExcludeGroups' },
@{ Label = 'Exclude roles'; Col = 'ExcludeRoles' }
)},
@{ Title = 'Applications'; Fields = @(
@{ Label = 'Include apps'; Col = 'IncludeApps' },
@{ Label = 'Exclude apps'; Col = 'ExcludeApps' },
@{ Label = 'Client app types';Col = 'ClientAppTypes' },
@{ Label = 'AuthN context'; Col = 'IncludeAuthenticationContext' }
)},
@{ Title = 'Conditions'; Fields = @(
@{ Label = 'Platforms include'; Col = 'IncludePlatforms' },
@{ Label = 'Platforms exclude'; Col = 'ExcludePlatforms' },
@{ Label = 'Locations include'; Col = 'IncludeLocations' },
@{ Label = 'Locations exclude'; Col = 'ExcludeLocations' },
@{ Label = 'Device filter mode'; Col = 'DeviceFilterMode' },
@{ Label = 'Device filter rule'; Col = 'DeviceFilterRule' },
@{ Label = 'Sign-in risk'; Col = 'SignInRiskLevels' },
@{ Label = 'User risk'; Col = 'UserRiskLevels' },
@{ Label = 'Authentication flows'; Col = 'AuthenticationFlows' }
)},
@{ Title = 'Grant'; Fields = @(
@{ Label = 'Operator'; Col = 'GrantControlsOperator' },
@{ Label = 'Controls'; Col = 'GrantControls' },
@{ Label = 'Auth strength'; Col = 'AuthenticationStrength' },
@{ Label = 'Allowed combos'; Col = 'AuthenticationStrengthAllowedCombinations' },
@{ Label = 'Terms of Use'; Col = 'TermsOfUseNames' }
)},
@{ Title = 'Session'; Fields = @(
@{ Label = 'App enforced restrictions'; Col = 'ApplicationEnforcedRestrictions' },
@{ Label = 'Defender for Cloud Apps'; Col = 'CloudAppSecurity' },
@{ Label = 'CAS mode'; Col = 'CloudAppSecurityMode' },
@{ Label = 'Persistent browser'; Col = 'PersistentBrowser' },
@{ Label = 'Sign-in frequency'; Col = 'SignInFrequency' },
@{ Label = 'Secure sign-in session'; Col = 'SecureSignInSession' },
@{ Label = 'GSA filtering profile'; Col = 'GlobalSecureAccessFilteringProfile' }
)}
)
$first = $documentation | Select-Object -First 1
$masterHeaders = @()
if ($first) { $masterHeaders = @($first.PSObject.Properties.Name) }
$headerIndex = @{}
for ($i=0; $i -lt $masterHeaders.Count; $i++) { $headerIndex[$masterHeaders[$i]] = $i+1 }
foreach ($item in $documentation) {
$sheetName = New-WorksheetName -Name $item.Name
Export-Excel -Path $ExcelPath -WorksheetName $sheetName -ClearSheet | Out-Null
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row 1 -Column 1 -Value 'Policy'
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row 1 -Column 2 -Value $item.Name
Try-SetFormat -Path $ExcelPath -WorksheetName $sheetName -Range 'A1:B1' -Bold -FontSize 14
$rowPtr = 3
foreach ($section in $sections) {
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 1 -Value $section.Title
Try-SetFormat -Path $ExcelPath -WorksheetName $sheetName -Range ("A$rowPtr:B$rowPtr") -Bold -BackgroundColor 'LightGray'
$rowPtr++
foreach ($f in $section.Fields) {
$label = $f.Label
$colName = $f.Col
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 1 -Value $label
if ($headerIndex.ContainsKey($colName)) {
$formula = "=INDEX(Master[$colName], MATCH(`$B`$1, Master[Name], 0))"
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 2 -Value $formula -Formula
} else {
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 2 -Value ''
}
$rowPtr++
}
$rowPtr++
}
Try-SetColumnWidth -Path $ExcelPath -WorksheetName $sheetName -Column 1 -Width 34
Try-SetColumnWidth -Path $ExcelPath -WorksheetName $sheetName -Column 2 -Width 80
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'enabled' -ForegroundColor 'Black' -BackgroundColor 'LightGreen'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'disabled' -ForegroundColor 'Black' -BackgroundColor 'LightGray'
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'reportOnly' -ForegroundColor 'Black' -BackgroundColor 'Khaki'
}
# Hyperlinks from Summary -> policy sheets
$r = 2
foreach ($name in $documentation | Select-Object -ExpandProperty Name) {
$ws = New-WorksheetName -Name $name
Set-Cell -Path $ExcelPath -WorksheetName 'Summary' -Row $r -Column 1 -Value $name -Hyperlink ("#'" + $ws + "'!A1")
$r++
}
Write-Output "Exported Excel workbook to '$ExcelPath'"
}
+682
View File
@@ -0,0 +1,682 @@
#requires -Version 7.0
<#
.SYNOPSIS
Generates a Conditional Access baseline YAML manifest from high-level security requirements.
.DESCRIPTION
Creates a CIS M365-compatible baseline YAML file covering Conditional Access policies.
The output can be reviewed and then deployed with Deploy-CISM365Baseline.ps1.
Policy names follow the structured naming convention:
<INDEX>-<TARGET>-<APP/RESOURCE>-<CONTROL>-<SCOPE>
Index ranges:
CA0xx User policies
CA1xx Guest policies
CA2xx Application policies
CA3xx Admin policies
CA4xx Threat policies
Example: CA001-AllUsers-AllApps-BlockLegacyAuth-Prod
.PARAMETER RequireTrustedLocations
Enforce that users can only sign in from trusted named locations.
- None: No location restriction policy
- AllUsers: All users must be on trusted locations
- Admins: Only administrative roles must be on trusted locations
- All: Both AllUsers and Admins policies
.PARAMETER AdminDeviceCompliance
Device requirements for administrative roles.
- None: No device policy for admins
- Required: Admins must use compliant or hybrid-joined devices
- RequiredWithMFA: Admins must use compliant/hybrid-joined devices AND MFA
.PARAMETER GuestMFA
Require MFA for guest and external users.
.PARAMETER SessionTimeoutHours
Require re-authentication after N hours. 0 disables session timeout policies.
.PARAMETER DisablePersistentBrowser
Prevent persistent browser sessions (users must re-auth when browser restarts).
.PARAMETER TrustedLocationsExemptFromReauth
When SessionTimeoutHours is set, do not require re-authentication from trusted locations.
This creates an exclusion so users on trusted networks are not nagged.
.PARAMETER RequireMFAForAllUsers
Require MFA for all member users.
.PARAMETER BlockLegacyAuth
Block all legacy authentication protocols (Exchange ActiveSync, basic auth, etc.).
.PARAMETER BlockHighRiskSignIns
Block sign-ins with medium or high risk level (requires Entra ID P2).
.PARAMETER RequireMFAForAdminPortals
Require MFA when accessing Microsoft admin portals (Azure, M365, Exchange, etc.).
.PARAMETER RequireMFAForAdmins
Require MFA for all administrative roles across all applications.
.PARAMETER RequirePhishingResistantMFAForAdmins
Require phishing-resistant MFA (FIDO2, certificate) for administrative roles.
.PARAMETER BlockDeviceCodeFlow
Block sign-ins using the device code authentication flow.
.PARAMETER RequireManagedDeviceForAllUsers
Require all users to use compliant or hybrid-joined devices.
.PARAMETER OutputPath
Path where the generated YAML baseline will be written.
.PARAMETER Scope
Deployment stage suffix applied to every policy name.
- Test, Pilot1, Pilot2, Pilot3, Prod
.PARAMETER UseDescriptiveNames
Use human-readable descriptive names instead of the structured naming convention.
.PARAMETER Prefix
Optional prefix applied before the INDEX (e.g. "ACME-" produces ACME-CA001-...).
.PARAMETER BreakGlassGroup
Name of the break-glass group to auto-exclude from every CA policy.
.PARAMETER ReportOnly
Default all generated policies to report-only mode (recommended for initial rollout).
.EXAMPLE
# Minimal baseline: MFA for all + block legacy auth
./Scripts/New-ConditionalAccessBaseline.ps1 `
-RequireMFAForAllUsers `
-BlockLegacyAuth `
-OutputPath ./Baselines/MyCA.yaml
.EXAMPLE
# Full security baseline with structured names scoped to production
./Scripts/New-ConditionalAccessBaseline.ps1 `
-RequireTrustedLocations AllUsers `
-AdminDeviceCompliance RequiredWithMFA `
-GuestMFA `
-SessionTimeoutHours 8 `
-DisablePersistentBrowser `
-TrustedLocationsExemptFromReauth `
-BlockLegacyAuth `
-BlockHighRiskSignIns `
-OutputPath ./Baselines/SecureTenant-CA.yaml `
-Scope Prod
.EXAMPLE
# Pilot rollout with descriptive names instead of structured convention
./Scripts/New-ConditionalAccessBaseline.ps1 `
-RequireMFAForAllUsers `
-BlockLegacyAuth `
-OutputPath ./Baselines/Pilot-CA.yaml `
-Scope Pilot1 `
-UseDescriptiveNames
#>
[CmdletBinding()]
param(
[Parameter()]
[ValidateSet('None','AllUsers','Admins','All')]
[string]$RequireTrustedLocations = 'None',
[Parameter()]
[ValidateSet('None','Required','RequiredWithMFA')]
[string]$AdminDeviceCompliance = 'None',
[Parameter()]
[switch]$GuestMFA,
[Parameter()]
[ValidateRange(0,24)]
[int]$SessionTimeoutHours = 0,
[Parameter()]
[switch]$DisablePersistentBrowser,
[Parameter()]
[switch]$TrustedLocationsExemptFromReauth,
[Parameter()]
[switch]$RequireMFAForAllUsers,
[Parameter()]
[switch]$BlockLegacyAuth,
[Parameter()]
[switch]$BlockHighRiskSignIns,
[Parameter()]
[switch]$RequireMFAForAdminPortals,
[Parameter()]
[switch]$RequireMFAForAdmins,
[Parameter()]
[switch]$RequirePhishingResistantMFAForAdmins,
[Parameter()]
[switch]$BlockDeviceCodeFlow,
[Parameter()]
[switch]$RequireManagedDeviceForAllUsers,
[Parameter(Mandatory = $true)]
[string]$OutputPath,
[Parameter()]
[ValidateSet('Test','Pilot1','Pilot2','Pilot3','Prod')]
[string]$Scope = 'Prod',
[Parameter()]
[switch]$UseDescriptiveNames,
[Parameter()]
[string]$Prefix = '',
[Parameter()]
[string]$BreakGlassGroup = 'CIS-BreakGlass',
[Parameter()]
[switch]$ReportOnly
)
$ErrorActionPreference = 'Stop'
# =====================================================================
# Naming convention engine
# =====================================================================
# Format: CA<area><scope><seq2digit>-<TARGET>-<APP/RESOURCE>-<CONTROL>
# Area: 0=Threat/Tenant, 1=User, 2=Admin, 3=Guest, 4=Application
# Scope: 0=Test, 1=Pilot1, 2=Pilot2, 3=Pilot3, 9=Prod
# Seq: auto-increment per area
# =====================================================================
$script:AreaDigitMap = @{
'User' = '1'
'Guest' = '3'
'Application' = '4'
'Admin' = '2'
'Threat' = '0'
}
$script:ScopeDigitMap = @{
'Test' = '0'
'Pilot1' = '1'
'Pilot2' = '2'
'Pilot3' = '3'
'Prod' = '9'
}
$script:NextSeq = @{
'0' = 1
'1' = 1
'2' = 1
'3' = 1
'4' = 1
}
function Get-StructuredPolicyName {
param(
[Parameter(Mandatory)]
[ValidateSet('User','Guest','Application','Admin','Threat')]
[string]$Category,
[Parameter(Mandatory)]
[string]$Target,
[Parameter(Mandatory)]
[string]$AppResource,
[Parameter(Mandatory)]
[string]$Control
)
$area = $script:AreaDigitMap[$Category]
$scope = $script:ScopeDigitMap[$Scope]
$seq = $script:NextSeq[$area]++
$idx = "$area$scope$($seq.ToString('D2'))"
$name = "CA$idx-${Target}-${AppResource}-${Control}"
if ($Prefix) { $name = "$Prefix$name" }
return $name
}
function Get-DescriptivePolicyName {
param([string]$Name)
if ($Prefix) { return "$Prefix$Name" }
return $Name
}
function Get-DefaultState {
if ($ReportOnly) { return 'enabledForReportingButNotEnforced' }
return 'enabled'
}
# =====================================================================
# Shared data
# =====================================================================
$script:AdminRoles = @(
'Global Administrator',
'Privileged Role Administrator',
'Security Administrator',
'Exchange Administrator',
'SharePoint Administrator',
'Conditional Access Administrator',
'Application Administrator',
'Cloud Application Administrator',
'User Administrator',
'Helpdesk Administrator',
'Billing Administrator',
'Authentication Administrator',
'Password Administrator'
)
$script:AdminPortalAppIds = @(
'797f4846-ba00-4fd7-ba43-dac1f8f63013', # Azure Management
'c44b4083-3bb0-49c1-b47d-974e53cbdf3c', # Azure AD PowerShell
'1b730954-1685-4b74-9bfd-dac224a7b894', # Microsoft Graph PowerShell
'00000003-0000-0ff1-ce00-000000000000', # Office 365 Exchange Online
'00000003-0000-0000-c000-000000000000', # Microsoft Graph
'de8bc8b5-d9f9-48b1-a8ad-b748da725064', # Microsoft Intune
'00000002-0000-0ff1-ce00-000000000000', # Office 365 SharePoint Online
'66a88757-258c-4c72-893c-3e8bed4d6899' # Microsoft365DSC
)
# =====================================================================
# Policy builders
# =====================================================================
function New-PolicyBlockLegacyAuth {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-Legacy-Authentication' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockLegacyAuth }
description = 'Block all legacy authentication protocols (EAS, basic auth, IMAP, POP, etc.)'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
clientAppTypes = @('exchangeActiveSync', 'other')
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireMFAAllUsers {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control RequireMFA }
description = 'Require multi-factor authentication for all users'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireMFAAdmins {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireMFA }
description = 'Require multi-factor authentication for all administrative roles'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeRoles = $script:AdminRoles }
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireMFAAdminPortals {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Admin-Portals' } else { Get-StructuredPolicyName -Category Application -Target AllUsers -AppResource AdminPortals -Control RequireMFA }
description = 'Require MFA when accessing Microsoft admin portals'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = $script:AdminPortalAppIds }
users = @{ includeUsers = @('All') }
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicyTrustedLocations {
param([switch]$ForAdmins)
if ($ForAdmins) {
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Trusted-Locations-Only-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control BlockUntrustedLocations }
$desc = 'Administrators can only sign in from trusted named locations'
$userDef = @{ includeRoles = $script:AdminRoles }
} else {
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Trusted-Locations-Only-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control BlockUntrustedLocations }
$desc = 'All users can only sign in from trusted named locations'
$userDef = @{ includeUsers = @('All') }
}
$policy = @{
name = $name
description = $desc
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = $userDef
locations = @{
includeLocations = @('All')
excludeLocations = @('AllTrusted')
}
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyAdminDeviceCompliance {
param([switch]$WithMFA)
$controls = @('compliantDevice', 'domainJoinedDevice')
$operator = 'OR'
$desc = 'Administrators must use compliant or hybrid-joined devices'
if ($WithMFA) {
$controls = @('compliantDevice', 'domainJoinedDevice', 'mfa')
$operator = 'AND'
$desc = 'Administrators must use compliant/hybrid-joined devices AND MFA'
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Compliant-Device-and-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireCompliantDeviceAndMFA }
} else {
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Compliant-Device-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireCompliantDevice }
}
$policy = @{
name = $name
description = $desc
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeRoles = $script:AdminRoles }
}
grantControls = @{
builtInControls = $controls
operator = $operator
}
}
return $policy
}
function New-PolicyGuestMFA {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Guests' } else { Get-StructuredPolicyName -Category Guest -Target Guests -AppResource AllApps -Control RequireMFA }
description = 'Require multi-factor authentication for guest and external users'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{
includeGuestsOrExternalUsers = @{
guestTypes = @('internalGuest', 'b2bCollaborationGuest', 'b2bCollaborationMember', 'b2bDirectConnectUser')
externalTenants = @{ membershipKind = 'all' }
}
}
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicySessionControls {
param(
[int]$TimeoutHours = 0,
[switch]$DisablePersistent,
[switch]$ExemptTrustedLocations
)
$sessionControls = @{}
$parts = [System.Collections.Generic.List[string]]::new()
if ($TimeoutHours -gt 0) {
$sessionControls['signInFrequency'] = @{
value = $TimeoutHours
type = 'hours'
isEnabled = $true
}
$parts.Add("re-authenticate every $TimeoutHours hours")
}
if ($DisablePersistent) {
$sessionControls['persistentBrowser'] = @{
mode = 'never'
isEnabled = $true
}
$parts.Add('no persistent browser sessions')
}
$desc = 'Session controls: ' + ($parts -join '; ')
if ($ExemptTrustedLocations) {
$desc += ' (exempt when on trusted locations)'
}
$controlTag = if ($TimeoutHours -gt 0 -and $DisablePersistent) {
'SessionControls'
} elseif ($TimeoutHours -gt 0) {
'SignInFrequency'
} else {
'NoPersistentBrowser'
}
$name = if ($UseDescriptiveNames) {
if ($TimeoutHours -gt 0 -and $DisablePersistent) {
Get-DescriptivePolicyName 'Session-Timeout-and-No-Persistent-Browser'
} elseif ($TimeoutHours -gt 0) {
Get-DescriptivePolicyName "Session-Timeout-${TimeoutHours}h"
} else {
Get-DescriptivePolicyName 'No-Persistent-Browser'
}
} else {
Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control $controlTag
}
$conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
}
if ($ExemptTrustedLocations) {
$conditions['locations'] = @{
excludeLocations = @('AllTrusted')
}
}
$policy = @{
name = $name
description = $desc
state = Get-DefaultState
conditions = $conditions
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
if ($sessionControls.Count -gt 0) {
$policy['sessionControls'] = $sessionControls
}
return $policy
}
function New-PolicyBlockHighRisk {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-High-Risk-SignIns' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockHighRisk }
description = 'Block sign-ins with medium or high risk score (requires Entra ID P2)'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
signInRiskLevels = @('medium', 'high')
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyPhishingResistantMFAAdmins {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-PhishingResistant-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequirePhishingResistantMFA }
description = 'Require phishing-resistant MFA (FIDO2, certificate) for administrative roles'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeRoles = $script:AdminRoles }
}
grantControls = @{
builtInControls = @('authenticationStrength')
authenticationStrength = @{ id = '00000000-0000-0000-0000-000000000004' }
operator = 'OR'
}
}
return $policy
}
function New-PolicyBlockDeviceCodeFlow {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-Device-Code-Flow' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockDeviceCodeFlow }
description = 'Block sign-ins using the device code authentication flow'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
authenticationFlows = @{
deviceCodeFlow = @{ isEnabled = $true }
}
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireManagedDeviceAllUsers {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Managed-Device-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control RequireCompliantDevice }
description = 'Require all users to use compliant or hybrid-joined devices'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
}
grantControls = @{
builtInControls = @('compliantDevice', 'domainJoinedDevice')
operator = 'OR'
}
}
return $policy
}
# =====================================================================
# Build the policy list based on parameters
# =====================================================================
$policies = [System.Collections.Generic.List[hashtable]]::new()
if ($BlockLegacyAuth) { $policies.Add((New-PolicyBlockLegacyAuth)) }
if ($RequireMFAForAllUsers) { $policies.Add((New-PolicyRequireMFAAllUsers)) }
if ($RequireMFAForAdmins) { $policies.Add((New-PolicyRequireMFAAdmins)) }
if ($RequireMFAForAdminPortals) { $policies.Add((New-PolicyRequireMFAAdminPortals)) }
if ($BlockHighRiskSignIns) { $policies.Add((New-PolicyBlockHighRisk)) }
if ($BlockDeviceCodeFlow) { $policies.Add((New-PolicyBlockDeviceCodeFlow)) }
if ($RequirePhishingResistantMFAForAdmins) { $policies.Add((New-PolicyPhishingResistantMFAAdmins)) }
if ($RequireManagedDeviceForAllUsers) { $policies.Add((New-PolicyRequireManagedDeviceAllUsers)) }
switch ($RequireTrustedLocations) {
'AllUsers' { $policies.Add((New-PolicyTrustedLocations)) }
'Admins' { $policies.Add((New-PolicyTrustedLocations -ForAdmins)) }
'All' { $policies.Add((New-PolicyTrustedLocations)); $policies.Add((New-PolicyTrustedLocations -ForAdmins)) }
}
switch ($AdminDeviceCompliance) {
'Required' { $policies.Add((New-PolicyAdminDeviceCompliance)) }
'RequiredWithMFA' { $policies.Add((New-PolicyAdminDeviceCompliance -WithMFA)) }
}
if ($GuestMFA) { $policies.Add((New-PolicyGuestMFA)) }
if ($SessionTimeoutHours -gt 0 -or $DisablePersistentBrowser) {
$policies.Add((New-PolicySessionControls `
-TimeoutHours $SessionTimeoutHours `
-DisablePersistent:$DisablePersistentBrowser `
-ExemptTrustedLocations:$TrustedLocationsExemptFromReauth))
}
if ($policies.Count -eq 0) {
throw "No policies requested. Specify at least one requirement parameter (e.g. -RequireMFAForAllUsers, -BlockLegacyAuth, etc.)."
}
# =====================================================================
# Serialize to YAML (requires powershell-yaml)
# =====================================================================
function Test-YamlModule {
return [bool](Get-Module -ListAvailable -Name powershell-yaml)
}
if (-not (Test-YamlModule)) {
Write-Host "powershell-yaml module is required but not installed." -ForegroundColor Yellow
$confirm = Read-Host "Install powershell-yaml from PSGallery now? [Y/n]"
if ($confirm -match "^\s*n") {
throw "powershell-yaml is required. Install it with: Install-Module powershell-yaml -Scope CurrentUser -Force"
}
Install-Module powershell-yaml -Scope CurrentUser -Force
}
Import-Module powershell-yaml -Force
# Build the root document
$yamlRoot = [ordered]@{
baseline = [ordered]@{
name = 'Generated-ConditionalAccess-Baseline'
conflictResolution = 'Skip'
whatIf = $false
tenantConfig = [ordered]@{
conditionalAccess = [ordered]@{
reportOnly = $true
breakGlassGroup = $BreakGlassGroup
policies = $policies
}
}
}
}
$yamlText = ConvertTo-Yaml -Data $yamlRoot
# Ensure output directory exists
$outDir = Split-Path -Parent $OutputPath
if ($outDir -and -not (Test-Path $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
$yamlText | Set-Content -Path $OutputPath -Encoding UTF8 -Force
Write-Host "Generated Conditional Access baseline with $($policies.Count) policies." -ForegroundColor Green
Write-Host "Output written to: $(Resolve-Path $OutputPath)" -ForegroundColor Green
Write-Host ""
Write-Host "Review the file, then deploy with:" -ForegroundColor Cyan
Write-Host " ./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath '$OutputPath' -Mode Assess" -ForegroundColor Yellow
Write-Host " ./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath '$OutputPath' -Mode Deploy -Apply" -ForegroundColor Yellow
@@ -0,0 +1,487 @@
#requires -Version 5.1
<#
.SYNOPSIS
Interactive terminal UI for IntuneManagement headless export/import.
.DESCRIPTION
Prompts for action, tenant, paths, filters, object types, and toggles.
Returns a PSCustomObject that Start-HeadlessIntune.ps1 consumes.
Uses fzf on macOS/Linux when available; falls back to numbered menus.
#>
[CmdletBinding()]
param(
[string]$TenantId,
[string]$Action
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfHint
{
if(Test-FzfAvailable) { return }
Write-Host "[fzf not found]" -ForegroundColor Yellow -NoNewline
Write-Host " Install fzf for the best interactive menu experience. Falling back to numbered menus." -ForegroundColor DarkGray
if($IsMacOS)
{
Write-Host " Install: brew install fzf" -ForegroundColor DarkGray
}
elseif($IsLinux)
{
Write-Host " Install: sudo apt install fzf (or dnf/pacman)" -ForegroundColor DarkGray
}
else
{
Write-Host " Install: winget install junegunn.fzf (or choco install fzf)" -ForegroundColor DarkGray
}
Write-Host ""
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
$argsList = @("--header=$Header")
if($Multi) { $argsList += "--multi" }
$selected = $Items | fzf @argsList
if(-not $selected) { return $null }
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one or more",
[switch]$Multi
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
if($Multi)
{
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
}
else
{
$prompt = "Enter a number"
}
$choice = Read-Host $prompt
if($choice -eq "all" -and $Multi) { return $Items }
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
if($Multi)
{
return $Items[$indices] | Select-Object -Unique
}
else
{
if($indices.Count -eq 0) { return $null }
return $Items[$indices[0]]
}
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one",
[switch]$Multi
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
}
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
}
function Select-FolderPath
{
param(
[Parameter(Mandatory)]
[string]$Prompt,
[string]$StartPath = (Get-Location).Path
)
if(-not (Test-FzfAvailable))
{
return Read-Host $Prompt
}
$current = if(Test-Path -LiteralPath $StartPath) { (Resolve-Path -LiteralPath $StartPath).Path } else { (Get-Location).Path }
while($true)
{
$entries = @("[Use this folder]", "[Type path manually]")
$parent = Split-Path -Path $current -Parent
if($parent) { $entries += ".." }
$subdirs = Get-ChildItem -LiteralPath $current -Directory -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { "$($_.Name)/" }
$entries += $subdirs
$choice = $entries | fzf --header="$Prompt [current: $current]"
if(-not $choice) { return $null }
switch ($choice)
{
"[Use this folder]" { return $current }
"[Type path manually]" { $typed = Read-Host "Enter path"; if(-not [string]::IsNullOrWhiteSpace($typed)) { return $typed }; continue }
".." { $current = $parent; continue }
default { $current = Join-Path $current ($choice.TrimEnd('/')); continue }
}
}
}
function Read-YesNo
{
param(
[string]$Prompt,
[bool]$Default = $false
)
$defaultChar = if($Default) { "Y" } else { "N" }
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
return $response -like 'y*'
}
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
#endregion
#region Load defaults
$modulePath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Headless/IntuneManagement.Headless.psd1"
Import-Module $modulePath -Force
$defaultTypes = Get-DefaultIntunePolicyObjectTypes
$settingsPath = Get-DefaultSettingsPath
$preloadedTenantId = $null
if(Test-Path $settingsPath)
{
try
{
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
if($settings.TenantId) { $preloadedTenantId = $settings.TenantId }
}
catch {}
}
#endregion
Show-FzfHint
while($true)
{
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " IntuneManagement Terminal UI" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
# 1. Action
if($Action)
{
$action = $Action
}
else
{
$action = Select-MenuItem -Items @("Export","Import","DeployCISBaseline","GenerateReports") -Header "Select action"
if(-not $action) { continue }
}
# CIS M365 Baseline deployment flow
if($action -eq "DeployCISBaseline")
{
# 2a. TenantId
if($TenantId)
{
$tenantId = $TenantId
Write-Host "Tenant: $tenantId" -ForegroundColor DarkGray
}
else
{
$tenantPrompt = "Enter Tenant ID"
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
$tenantId = Read-Host $tenantPrompt
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
}
# 2b. Baseline path
$defaultBaseline = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Baselines/CISM365-v7-Generated.yaml"
$baselinePath = Read-Host "Baseline YAML path (default: $defaultBaseline)"
if([string]::IsNullOrWhiteSpace($baselinePath)) { $baselinePath = $defaultBaseline }
if(-not (Test-Path $baselinePath)) { Write-Host "Baseline file not found: $baselinePath" -ForegroundColor Red; continue }
# 2c. Mode
$mode = Select-MenuItem -Items @("Assess","Deploy") -Header "Select mode"
if(-not $mode) { continue }
# 2d. Apply (only for Deploy)
$apply = $false
if($mode -eq "Deploy")
{
$apply = Read-YesNo -Prompt "Apply changes? (No = dry-run report)" -Default $false
}
# 2e. Workloads
$allWorkloads = @("EntraID","ConditionalAccess","Exchange","SharePoint","Teams","PowerBI","Defender","Purview")
Write-Host "`nWorkload selection..." -ForegroundColor Cyan
$workloadSelection = Select-MenuItem -Items $allWorkloads -Header "Select workloads (Space to multi-select, or choose 'all')" -Multi
if(-not $workloadSelection) { $workloadSelection = $allWorkloads }
# 2f. Auth mode
$authMode = Select-MenuItem -Items @("AppOnly","Browser","DeviceCode") -Header "Select authentication mode"
if(-not $authMode) { $authMode = "Browser" }
# 2g. Review
Clear-Host
Write-Host "Review your CIS M365 Baseline deployment:" -ForegroundColor Green
Write-Host " TenantId : $tenantId"
Write-Host " Baseline : $baselinePath"
Write-Host " Mode : $mode"
if($mode -eq "Deploy") { Write-Host " Apply : $apply" }
Write-Host " Workloads : $($workloadSelection -join ', ')"
Write-Host " Auth Mode : $authMode"
$confirm = Read-Host "`nProceed? [Y/n] (or type 'back' to restart)"
if($confirm -eq "back") { continue }
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
{
Write-Host "Cancelled." -ForegroundColor Yellow
continue
}
$result = [PSCustomObject]@{
Action = $action
TenantId = $tenantId
BaselinePath = $baselinePath
Mode = $mode
Apply = $apply
Workloads = $workloadSelection
AuthMode = $authMode
}
return $result
}
# Generate Reports flow
if($action -eq "GenerateReports")
{
$reportTypes = @("Settings","Assignments","ObjectInventory","All")
$reportType = Select-MenuItem -Items $reportTypes -Header "Select report type"
if(-not $reportType) { continue }
$dataSource = Select-MenuItem -Items @("Use existing backup","Pull fresh data from tenant") -Header "Data source"
if(-not $dataSource) { continue }
$backupRoot = $null
$tenantIdForReport = $null
$exportPath = $null
if($dataSource -like "*fresh*")
{
if($TenantId)
{
$tenantIdForReport = $TenantId
Write-Host "Tenant: $tenantIdForReport" -ForegroundColor DarkGray
}
else
{
$tenantPrompt = "Enter Tenant ID"
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
$tenantIdForReport = Read-Host $tenantPrompt
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { $tenantIdForReport = $preloadedTenantId }
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
}
$exportPath = Select-FolderPath -Prompt "Select export path (where to save fresh data)"
if([string]::IsNullOrWhiteSpace($exportPath)) { Write-Host "Export path is required." -ForegroundColor Red; continue }
$backupRoot = $exportPath
}
else
{
$backupRoot = Select-FolderPath -Prompt "Select backup root (folder containing 'Settings Catalog', etc.)"
if([string]::IsNullOrWhiteSpace($backupRoot)) { Write-Host "Backup root is required." -ForegroundColor Red; continue }
if(-not (Test-Path $backupRoot)) { Write-Host "Path not found: $backupRoot" -ForegroundColor Red; continue }
}
$outputDir = Select-FolderPath -Prompt "Select output directory for reports"
if([string]::IsNullOrWhiteSpace($outputDir)) { Write-Host "Output directory is required." -ForegroundColor Red; continue }
$includeAssignmentsInSettings = $false
if($reportType -in @("Settings","All"))
{
$includeAssignmentsInSettings = Read-YesNo -Prompt "Include assignment columns in settings report?" -Default $false
}
Clear-Host
Write-Host "Review report generation:" -ForegroundColor Green
Write-Host " Report Type : $reportType"
Write-Host " Data Source : $dataSource"
if($dataSource -like "*fresh*")
{
Write-Host " Tenant ID : $tenantIdForReport"
Write-Host " Export Path : $exportPath"
}
else
{
Write-Host " Backup Root : $backupRoot"
}
Write-Host " Output Dir : $outputDir"
if($reportType -in @("Settings","All"))
{
Write-Host " Include Assignments : $includeAssignmentsInSettings"
}
$confirm = Read-Host "`nProceed? [Y/n] (or type 'back' to restart)"
if($confirm -eq "back") { continue }
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -like 'y*'))
{
Write-Host "Cancelled." -ForegroundColor Yellow; continue
}
$result = [PSCustomObject]@{
Action = "GenerateReports"
DataSource = $dataSource
ReportType = $reportType
BackupRoot = $backupRoot
OutputDir = $outputDir
IncludeAssignmentsInSettings = $includeAssignmentsInSettings
}
if($dataSource -like "*fresh*")
{
$result | Add-Member -NotePropertyName TenantId -NotePropertyValue $tenantIdForReport
$result | Add-Member -NotePropertyName ExportPath -NotePropertyValue $exportPath
}
return $result
}
# 2. TenantId
if($TenantId)
{
$tenantId = $TenantId
Write-Host "`nTenant: $tenantId" -ForegroundColor DarkGray
}
else
{
$tenantPrompt = "Enter Tenant ID"
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
$tenantId = Read-Host $tenantPrompt
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
}
# 3. Object Types
Write-Host "`nObject type selection..." -ForegroundColor Cyan
$typeSelection = Select-MenuItem -Items $defaultTypes -Header "Select object types to include (Space to multi-select)" -Multi
if(-not $typeSelection) { continue }
# 4. Path
$pathPrompt = if($action -eq "Export") { "Select export root folder" } else { "Select import root folder" }
$path = Select-FolderPath -Prompt $pathPrompt
if([string]::IsNullOrWhiteSpace($path)) { Write-Host "Path is required." -ForegroundColor Red; return $null }
# 5. Name Filter
$nameFilter = Read-Host "Name filter, text to match anywhere in name, case-insensitive (optional, e.g. 'Win-OIB-')"
# 6. Name Mutation
$nameSearchPattern = Read-Host "Name search regex for mutation (optional, e.g. '^Win-OIB-')"
$nameReplacePattern = $null
if(-not [string]::IsNullOrWhiteSpace($nameSearchPattern))
{
$nameReplacePattern = Read-Host "Replacement string (e.g. 'Win-TEST-')"
}
# 7. Import-specific options
$importType = $null
$includeScopeTags = $false
$replaceDependencyIds = $false
if($action -eq "Import")
{
$importType = Select-MenuItem -Items @("alwaysImport","skipIfExist","replace","replace_with_assignments","update") -Header "Select import behavior"
if(-not $importType) { $importType = "alwaysImport" }
$includeScopeTags = Read-YesNo -Prompt "Import scope tags?" -Default $false
$replaceDependencyIds = Read-YesNo -Prompt "Replace dependency IDs?" -Default $false
}
# 8. Common toggles
$includeAssignments = Read-YesNo -Prompt "Include assignments?" -Default $false
$addCompanyName = $false
if($action -eq "Export")
{
$addCompanyName = Read-YesNo -Prompt "Add company name to folders?" -Default $false
}
# 9. Review
Clear-Host
Write-Host "Review your selection:" -ForegroundColor Green
Write-Host " Action : $action"
Write-Host " TenantId : $tenantId"
Write-Host " Object Types : $($typeSelection -join ', ')"
if($action -eq "Export")
{
Write-Host " Export Path : $path"
Write-Host " Add Company Name : $addCompanyName"
}
else
{
Write-Host " Import Path : $path"
Write-Host " Import Type : $importType"
Write-Host " Include Scope Tags : $includeScopeTags"
Write-Host " Replace Dep IDs : $replaceDependencyIds"
}
Write-Host " Name Filter : $(if($nameFilter){$nameFilter}else{'(none)'})"
Write-Host " Name Search Pattern : $(if($nameSearchPattern){$nameSearchPattern}else{'(none)'})"
Write-Host " Name Replace Pattern: $(if($nameReplacePattern){$nameReplacePattern}else{'(none)'})"
Write-Host " Include Assignments : $includeAssignments"
$confirm = Read-Host "`nProceed? [Y/n] (or type 'back' to restart)"
if($confirm -eq "back") { continue }
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
{
Write-Host "Cancelled." -ForegroundColor Yellow
continue
}
# 10. Build result
$result = [PSCustomObject]@{
Action = $action
TenantId = $tenantId
ObjectTypes = $typeSelection
NameFilter = $nameFilter
NameSearchPattern = $nameSearchPattern
NameReplacePattern = $nameReplacePattern
IncludeAssignments = $includeAssignments
}
if($action -eq "Export")
{
$result | Add-Member -NotePropertyName ExportPath -NotePropertyValue $path
$result | Add-Member -NotePropertyName AddCompanyName -NotePropertyValue $addCompanyName
}
else
{
$result | Add-Member -NotePropertyName ImportPath -NotePropertyValue $path
$result | Add-Member -NotePropertyName ImportType -NotePropertyValue $importType
$result | Add-Member -NotePropertyName IncludeScopeTags -NotePropertyValue $includeScopeTags
$result | Add-Member -NotePropertyName ReplaceDependencyIds -NotePropertyValue $replaceDependencyIds
}
return $result
}
+99
View File
@@ -0,0 +1,99 @@
#requires -Version 7.0
<#
.SYNOPSIS
Launches the interactive Conditional Access Policy Wizard (TUI).
.DESCRIPTION
Starts the Python-based TUI wizard that guides you through tenant,
user, admin, guest, and application policy choices. The wizard
generates a deployment-ready YAML baseline using the structured
naming convention.
Automatically locates the project venv or system Python with the
required packages (rich, pyyaml).
.EXAMPLE
./Scripts/Start-CAWizard.ps1
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$wizardPath = Join-Path $PSScriptRoot 'ca-wizard.py'
if (-not (Test-Path $wizardPath)) {
throw "Wizard script not found: $wizardPath"
}
# =====================================================================
# Resolve Python interpreter
# =====================================================================
function Test-PythonPackages {
param([string]$PyExe)
if (-not $PyExe) { return $false }
try {
$result = & $PyExe -c "import rich, yaml" 2>&1
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
$candidates = [System.Collections.Generic.List[string]]::new()
# 1. Project venv (Linux/macOS)
$venvPy = Join-Path (Split-Path $PSScriptRoot -Parent) '.venv-pdf/bin/python3'
if (Test-Path $venvPy) { $candidates.Add($venvPy) }
# 2. Project venv (Windows)
$venvPyWin = Join-Path (Split-Path $PSScriptRoot -Parent) '.venv-pdf/Scripts/python.exe'
if (Test-Path $venvPyWin) { $candidates.Add($venvPyWin) }
# 3. Common system commands
foreach ($cmd in @('python3', 'python')) {
$found = Get-Command $cmd -ErrorAction SilentlyContinue
if ($found) { $candidates.Add($found.Source) }
}
$pythonPath = $null
foreach ($c in $candidates) {
if (Test-PythonPackages -PyExe $c) {
$pythonPath = $c
break
}
}
# If nothing has the packages, try installing into the venv
if (-not $pythonPath) {
$venvPy = $candidates | Where-Object { $_ -match '\.venv' } | Select-Object -First 1
if ($venvPy -and (Test-Path $venvPy)) {
Write-Host "Installing required packages into venv..." -ForegroundColor Yellow
$pip = Join-Path (Split-Path $venvPy -Parent) 'pip'
if (-not (Test-Path $pip)) { $pip = Join-Path (Split-Path $venvPy -Parent) 'pip3' }
& $pip install rich pyyaml 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
if (Test-PythonPackages -PyExe $venvPy) {
$pythonPath = $venvPy
}
}
}
if (-not $pythonPath) {
throw @"
Could not find a Python interpreter with 'rich' and 'pyyaml' installed.
Please install the requirements:
python3 -m pip install rich pyyaml
Or activate the project venv manually:
source .venv-pdf/bin/activate
python3 Scripts/ca-wizard.py
"@
}
Write-Host "Using Python: $pythonPath" -ForegroundColor DarkGray
# =====================================================================
# Run wizard
# =====================================================================
& $pythonPath $wizardPath
+263
View File
@@ -0,0 +1,263 @@
[CmdletBinding()]
param(
[ValidateSet("Export","Import","DeployCISBaseline","GenerateReports")]
[string]$Action,
[string]$BaselinePath,
[ValidateSet("Assess","Deploy")]
[string]$Mode = "Assess",
[string[]]$Workloads,
[switch]$Apply,
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile,
[string]$BatchFile,
[string]$NameFilter = "",
[string]$NameSearchPattern = "",
[string]$NameReplacePattern = "",
[string[]]$ObjectTypes,
[string]$ExportPath,
[string]$ImportPath,
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
[string]$ImportType = "alwaysImport",
[switch]$IncludeAssignments,
[switch]$AddCompanyName,
[switch]$IncludeScopeTags,
[switch]$ReplaceDependencyIds,
[switch]$Interactive,
# GenerateReports params
[ValidateSet("Settings","Assignments","ObjectInventory","All")]
[string]$ReportType = "All",
[string]$BackupRoot,
[string]$OutputDir,
[string]$DataSource,
[switch]$IncludeAssignmentsInSettings
)
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
Import-Module $modulePath -Force
if($Interactive -and -not $Action)
{
Write-Host "Interactive mode will prompt for the action and other settings." -ForegroundColor Cyan
}
elseif(-not $Action)
{
throw "Action is required. Use -Interactive to select it in a terminal UI."
}
if($Interactive)
{
$tuiScript = Join-Path (Split-Path -Parent $PSScriptRoot) "Scripts/Private/Start-IntuneManagementTui.ps1"
if(Test-Path $tuiScript)
{
$tuiResult = & $tuiScript -TenantId $TenantId -Action $Action
if(-not $tuiResult) { Write-Host "No selection made. Exiting." -ForegroundColor Yellow; exit 0 }
foreach($prop in $tuiResult.PSObject.Properties)
{
if($null -ne $prop.Value -and $prop.Name -ne "Action")
{
Set-Variable -Name $prop.Name -Value $prop.Value
}
elseif($prop.Name -eq "Action")
{
$Action = $prop.Value
}
}
}
else
{
throw "TUI script not found: $tuiScript"
}
}
if($Action -eq "GenerateReports")
{
if([string]::IsNullOrWhiteSpace($OutputDir)) { throw "OutputDir is required for GenerateReports." }
if($DataSource -like "*fresh*")
{
if([string]::IsNullOrWhiteSpace($TenantId)) { throw "TenantId is required when pulling fresh data." }
$freshDest = if(-not [string]::IsNullOrWhiteSpace($ExportPath)) { $ExportPath } else { $BackupRoot }
if([string]::IsNullOrWhiteSpace($freshDest)) { throw "ExportPath or BackupRoot required for fresh data pull." }
Write-Host "Pulling fresh data from tenant $TenantId ..." -ForegroundColor Cyan
$freshParams = @{ Action = "Export"; TenantId = $TenantId; ExportPath = $freshDest; IncludeAssignments = $true; AuthMode = $AuthMode }
if($AppId) { $freshParams.AppId = $AppId }
if($Secret) { $freshParams.Secret = $Secret }
elseif($Certificate) { $freshParams.Certificate = $Certificate }
if($SettingsFile) { $freshParams.SettingsFile = $SettingsFile }
Invoke-IntunePolicyAction @freshParams
$BackupRoot = $freshDest
}
# Validate inputs
if([string]::IsNullOrWhiteSpace($BackupRoot)) { throw "BackupRoot is required for GenerateReports." }
if(-not (Test-Path $BackupRoot)) { throw "BackupRoot not found: $BackupRoot" }
$python = Get-Command python3 -ErrorAction SilentlyContinue
if(-not $python) { $python = Get-Command python -ErrorAction SilentlyContinue }
if(-not $python) { throw "python3 not found. Install Python 3 to use GenerateReports." }
$pythonExe = $python.Source
$scriptsDir = Split-Path -Parent $PSScriptRoot
if(-not (Test-Path (Join-Path $scriptsDir "Scripts/Export-SettingsReport.py")))
{
$scriptsDir = $PSScriptRoot
}
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
function Invoke-Report
{
param([string]$Script, [string[]]$ScriptArgs)
$fullScript = Join-Path $scriptsDir "Scripts/$Script"
if(-not (Test-Path $fullScript)) { Write-Warning "Report script not found: $fullScript"; return }
Write-Host "Running $Script ..." -ForegroundColor Cyan
& $pythonExe $fullScript @ScriptArgs
}
if($ReportType -in @("Settings","All"))
{
$settingsArgs = @("--root", $BackupRoot, "--output", (Join-Path $OutputDir "settings-report.csv"))
if($IncludeAssignmentsInSettings) { $settingsArgs += "--include-assignments" }
Invoke-Report -Script "Export-SettingsReport.py" -ScriptArgs $settingsArgs
}
if($ReportType -in @("Assignments","All"))
{
Invoke-Report -Script "Export-AssignmentReport.py" -ScriptArgs @(
"--root", $BackupRoot,
"--output", (Join-Path $OutputDir "assignment-report.csv")
)
}
if($ReportType -in @("ObjectInventory","All"))
{
Invoke-Report -Script "Export-ObjectInventoryReport.py" -ScriptArgs @(
"--root", $BackupRoot,
"--output", (Join-Path $OutputDir "object-inventory.csv")
)
}
Write-Host "`nReports written to: $OutputDir" -ForegroundColor Green
return
}
if($Action -eq "DeployCISBaseline")
{
$deployScript = Join-Path (Split-Path -Parent $PSScriptRoot) "Scripts/Deploy-CISM365Baseline.ps1"
if(-not (Test-Path $deployScript))
{
throw "CIS baseline deployment script not found: $deployScript"
}
$deployParams = @{
BaselinePath = $BaselinePath
TenantId = $TenantId
Mode = $Mode
AuthMode = $AuthMode
}
if($Apply) { $deployParams.Apply = $true }
if($PSBoundParameters.ContainsKey("Workloads") -or $Workloads)
{
$deployParams.Workloads = $Workloads
}
if($Secret)
{
$deployParams.Secret = $Secret
}
elseif($Certificate)
{
$deployParams.Certificate = $Certificate
}
if($AppId) { $deployParams.AppId = $AppId }
if($RedirectUri) { $deployParams.RedirectUri = $RedirectUri }
& $deployScript @deployParams
return
}
if([string]::IsNullOrWhiteSpace($TenantId))
{
throw "TenantId is required for Action '$Action'."
}
$invokeParams = @{
Action = $Action
TenantId = $TenantId
AppId = $AppId
AuthMode = $AuthMode
SettingsFile = $SettingsFile
BatchFile = $BatchFile
NameFilter = $NameFilter
NameSearchPattern = $NameSearchPattern
NameReplacePattern = $NameReplacePattern
ExportPath = $ExportPath
ImportPath = $ImportPath
ImportType = $ImportType
IncludeAssignments = $IncludeAssignments
AddCompanyName = $AddCompanyName
IncludeScopeTags = $IncludeScopeTags
ReplaceDependencyIds = $ReplaceDependencyIds
}
if($Interactive -and $Action) { $invokeParams.Action = $Action }
if($PSBoundParameters.ContainsKey("ObjectTypes") -or $ObjectTypes)
{
$invokeParams.ObjectTypes = $ObjectTypes
}
if($Secret)
{
$invokeParams.Secret = $Secret
}
elseif($Certificate)
{
$invokeParams.Certificate = $Certificate
}
if($RedirectUri)
{
$invokeParams.RedirectUri = $RedirectUri
}
Invoke-IntunePolicyAction @invokeParams
+778
View File
@@ -0,0 +1,778 @@
#!/usr/bin/env python3
"""
Convert CIS M365 v7.0.0 draft PDF to YAML baseline manifest.
Called by ConvertFrom-CISPDF.ps1
"""
import sys
import re
from pathlib import Path
from pypdf import PdfReader
def parse_profiles(pa_text: str | None) -> set[tuple[str, str]]:
"""Extract (level, license) tuples from Profile Applicability text.
Example: '• E3 Level 1 • E5 Level 2'{('L1','E3'), ('L2','E5')}
"""
if not pa_text:
return set()
profiles = set()
# Split by bullet to avoid cross-bullet matching
bullets = re.split(r'\s*•\s*', pa_text)
for bullet in bullets:
bullet = bullet.strip()
if not bullet:
continue
# Look for patterns like "E3 Level 1" or "Level 1 E3" within a single bullet
m = re.search(r'\b(E3|E5)\b.*\bLevel\s+(1|2)\b', bullet, re.IGNORECASE)
if not m:
m = re.search(r'\bLevel\s+(1|2)\b.*\b(E3|E5)\b', bullet, re.IGNORECASE)
if m:
level = f"L{m.group(1)}"
license = m.group(2).upper()
profiles.add((level, license))
else:
level = f"L{m.group(2)}"
license = m.group(1).upper()
profiles.add((level, license))
return profiles
def format_profiles(profiles: set[tuple[str, str]]) -> str:
"""Format profile set as compact badge string."""
if not profiles:
return ""
return "[" + ", ".join(f"{lvl}·{lic}" for lvl, lic in sorted(profiles)) + "]"
def matches_filter(profiles: set[tuple[str, str]], level_filter: str, license_filter: str) -> bool:
"""Check if a control's profiles match the requested level/license filters.
A control matches if at least one of its (level, license) tuples matches both filters.
"""
if not profiles:
return True # If we can't parse profiles, include by default
for lvl, lic in profiles:
level_ok = level_filter == 'Both' or level_filter == lvl
license_ok = license_filter == 'Both' or license_filter == lic
if level_ok and license_ok:
return True
return False
def parse_pdf(pdf_path: str) -> list[dict]:
"""Extract and parse all recommendations from the PDF."""
reader = PdfReader(pdf_path)
full_text = ""
for page in reader.pages:
full_text += "\n" + (page.extract_text() or "")
m = re.search(r'Profile Applicability:\s*\n\s*•\s*E3', full_text)
content_start = m.start() if m else 0
content = full_text[content_start:]
content = re.sub(r'\nPage \d+\s*\n', '\n', content)
section_headers = {
'Overview', 'Groups', 'Devices', 'Enterprise apps', 'External Identities',
'User experiences', 'Authentication Methods', 'Password reset', 'Identity Protection',
'Conditional Access', 'Protection', 'Hybrid management', 'Audit', 'Mail flow',
'Roles', 'Mobile Device Management', 'Application Permissions', 'Settings',
'Teams & groups', 'Users', 'External sharing', 'Guest access', 'Device access',
'User risk', 'Sign-in risk', 'Access reviews', 'Privileged Identity Management',
'Administration center', 'Email and collaboration', 'Tenant settings',
'Meetings', 'Messaging', 'Teams and channels', 'App permissions',
'External access', 'Data sharing', 'File sharing', 'Site settings',
'Service principals', 'Workspaces', 'External domains', 'External emails',
'Meeting policies', 'Calling policies', 'Teams policies', 'Channel policies',
'App setup policies', 'Permission policies', 'Update policies',
'Compliance policies', 'Retention policies', 'Sensitivity labels',
'Data loss prevention', 'Information barriers', 'Communication compliance',
'Insider risk management', 'Records management', 'eDiscovery',
'Customer Lockbox', 'Audit log', 'Reports', 'Alerts',
'Anti-spam', 'Anti-malware', 'Anti-phishing', 'Safe Attachments',
'Safe Links', 'Outbound spam', 'Connection filter', 'Mail flow rules',
'Transport rules', 'Journal rules', 'Data connectors',
'Sensitivity label policies', 'Auto-labeling policies',
'Information protection', 'Data governance', 'Compliance Manager',
'Service assurance', 'Health', 'Message center', 'Adoption Score',
'Usage reports', 'Productivity Score', 'Org settings',
'Security & Privacy', 'Organization profile', 'Partner relationships',
'Billing', 'Purchase services', 'Subscriptions', 'Licenses',
'Payment methods', 'Billing notifications', 'Invoice',
'Active users', 'Deleted users', 'Guest users',
'Contacts', 'Sign-in options',
'Custom domain names', 'DNS records', 'Domain settings',
'Shared mailboxes', 'Resource mailboxes', 'Distribution groups',
'Dynamic distribution groups', 'Mail-enabled security groups',
'Office 365 groups', 'Security groups', 'Mail contacts',
'Migration', 'Data migration', 'IMAP migration',
'Cutover migration', 'Staged migration', 'Minimal hybrid',
'Express migration', 'Cross-tenant migration',
'Setup', 'Connectors',
'Azure AD', 'Support',
'Training', 'Policies', 'Resources', 'Mail',
'Sites', 'Apps', 'Power Platform',
'Dynamics 365', 'Azure', 'Microsoft 365',
'Intune', 'Entra', 'Exchange', 'SharePoint',
'OneDrive', 'Power BI', 'Power Apps',
'Power Automate', 'Power Virtual Agents', 'Copilot',
}
pa_positions = [m.start() for m in re.finditer(r'Profile Applicability:', content)]
recommendations = []
for i, pa_pos in enumerate(pa_positions):
window_start = max(0, pa_pos - 800)
window = content[window_start:pa_pos]
title_match = None
for m in re.finditer(r'(\d+\.\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
title_match = m
if not title_match:
for m in re.finditer(r'(\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
title_match = m
if not title_match:
continue
control_num = title_match.group(1)
title = title_match.group(2).replace('\n', ' ').strip()
title = re.sub(r'\s+', ' ', title)
status = title_match.group(3)
if title in section_headers:
continue
rec_start = title_match.start() + window_start
rec_end = pa_positions[i + 1] if i + 1 < len(pa_positions) else len(content)
chunk = content[rec_start:rec_end]
def extract_field(field_name: str, chunk_text: str) -> str | None:
pattern = re.compile(
re.escape(field_name) + r':\s*\n?\s*(.*?)(?=\n\s*[A-Z][a-zA-Z\s]+:\s*\n|\Z)',
re.DOTALL
)
m = pattern.search(chunk_text)
if m:
val = m.group(1).strip()
val = re.sub(r'\s+', ' ', val)
return val
return None
rec = {
'control': control_num,
'title': title,
'status': status,
'profile_applicability': extract_field('Profile Applicability', chunk),
'description': extract_field('Description', chunk),
'rationale': extract_field('Rationale', chunk),
'impact': extract_field('Impact', chunk),
'default_value': extract_field('Default Value', chunk),
}
rem_match = re.search(r'Remediation:\s*(.*?)(?=Audit:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
if rem_match:
rec['remediation'] = re.sub(r'\s+', ' ', rem_match.group(1))[:1000]
audit_match = re.search(r'Audit:\s*(.*?)(?=Remediation:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
if audit_match:
rec['audit'] = re.sub(r'\s+', ' ', audit_match.group(1))[:1000]
recommendations.append(rec)
seen = set()
unique = []
for r in recommendations:
if r['control'] not in seen:
seen.add(r['control'])
unique.append(r)
return unique
def generate_yaml(recommendations: list[dict], prefix: str, level_filter: str = 'Both', license_filter: str = 'Both') -> str:
"""Generate YAML baseline from parsed recommendations."""
lines = []
lines.append("# =====================================================================")
lines.append("# CIS Microsoft 365 Foundations Benchmark v7.0.0 (Draft)")
lines.append("# GENERATED from PDF — review before deploying")
lines.append("# =====================================================================")
lines.append("")
lines.append("baseline:")
lines.append(f' name: CIS-M365-v7-Generated')
lines.append(' conflictResolution: Skip')
lines.append(' whatIf: false')
lines.append("")
lines.append(' tenantMutation:')
lines.append(f' prefix: "{prefix}"')
lines.append("")
lines.append(' groups:')
lines.append(' - displayName: "CIS-BreakGlass"')
lines.append(' mailNickname: "CISBreakGlass"')
lines.append(' securityEnabled: true')
lines.append(' - displayName: "CIS-Pilot-Users"')
lines.append(' mailNickname: "CISPilotUsers"')
lines.append(' securityEnabled: true')
lines.append("")
lines.append(' tenantConfig:')
section_names = {
'1': 'adminCenter',
'2': 'defender',
'3': 'purview',
'5': 'entraId',
'6': 'exchange',
'7': 'sharePoint',
'8': 'teams',
'9': 'powerBI',
}
# =====================================================================
# COMPREHENSIVE CONTROL MAPPINGS
# =====================================================================
# Simple scalar/boolean mappings: control -> (yaml_section, yaml_key, value)
simple_mappings = {
# --- Section 1: Admin Center ---
'1.3.1': ('adminCenter', 'passwordExpiration', 'NeverExpire'),
'1.3.2': ('adminCenter', 'idleSessionTimeoutHours', 3),
'1.3.4': ('adminCenter', 'restrictUserOwnedApps', True),
'1.3.5': ('adminCenter', 'formsPhishingProtection', True),
'1.3.6': ('adminCenter', 'customerLockbox', True),
'1.3.7': ('adminCenter', 'restrictThirdPartyStorage', True),
'1.3.9': ('adminCenter', 'restrictSharedBookings', True),
'1.3.3': ('adminCenter', 'externalCalendarSharing', 'Disabled'),
# --- Section 5: Entra ID ---
'5.1.2.2': ('entraId', 'blockUserConsent', True),
'5.1.2.3': ('entraId', 'blockTenantCreation', True),
'5.1.2.4': ('entraId', 'restrictAdminCenterAccess', True),
'5.1.2.6': ('entraId', 'disableLinkedIn', True),
'5.1.3.1': ('entraId', 'blockSecurityGroupCreation', True),
'5.1.3.4': ('entraId', 'blockM365GroupCreation', True),
'5.1.4.1': ('entraId', 'restrictDeviceJoin', True),
'5.1.4.2': ('entraId', 'maxDevicesPerUser', 5),
'5.1.4.3': ('entraId', 'gaLocalAdminDisabled', True),
'5.1.4.4': ('entraId', 'limitLocalAdminAssignment', True),
'5.1.4.5': ('entraId', 'enableLAPS', True),
'5.1.4.6': ('entraId', 'restrictBitLockerRecovery', True),
'5.1.5.1': ('entraId', 'blockUserConsent', True),
'5.1.5.2': ('entraId', 'enableAdminConsentWorkflow', True),
'5.1.5.3': ('entraId', 'blockPasswordCredentials', True),
'5.1.5.4': ('entraId', 'maxPasswordLifetimeDays', 180),
'5.1.5.5': ('entraId', 'systemGeneratedPasswords', True),
'5.1.5.6': ('entraId', 'maxCertificateLifetimeDays', 180),
'5.1.6.1': ('entraId', 'restrictCollaborationDomains', True),
'5.1.6.2': ('entraId', 'restrictGuestAccess', True),
'5.1.6.3': ('entraId', 'limitGuestInvitations', True),
'5.1.8.1': ('entraId', 'enablePasswordHashSync', True),
'5.2.3.1': ('entraId', 'authenticatorNumberMatching', True),
'5.2.3.4': ('entraId', 'mfaCapableUsers', True),
'5.2.3.5': ('entraId', 'disableWeakAuthMethods', True),
'5.2.3.6': ('entraId', 'systemPreferredMFA', True),
'5.2.3.7': ('entraId', 'disableEmailOTP', True),
'5.2.3.8': ('entraId', 'lockoutThreshold', 10),
'5.2.3.9': ('entraId', 'lockoutDurationSeconds', 60),
'5.2.3.10': ('entraId', 'disableAuthenticatorCompanionApps', True),
'5.3.1': ('entraId', 'pimRoleActivationRequired', True),
'5.3.2': ('entraId', 'accessReviewsForGuests', True),
'5.3.3': ('entraId', 'accessReviewsForPrivilegedRoles', True),
'5.3.4': ('entraId', 'requireApprovalForGAActivation', True),
'5.3.5': ('entraId', 'requireApprovalForPRAActivation', True),
# --- Section 6: Exchange ---
'6.1.1': ('exchange', 'enableMailboxAuditOrgWide', True),
'6.1.2': ('exchange', 'configureMailboxAuditActions', True),
'6.1.3': ('exchange', 'disableAuditBypass', True),
'6.2.1': ('exchange', 'blockExternalForwarding', True),
'6.2.2': ('exchange', 'noDomainWhitelistTransportRules', True),
'6.2.3': ('exchange', 'enableExternalSenderBanner', True),
'6.3.1': ('exchange', 'blockOutlookAddIns', True),
'6.3.2': ('exchange', 'disablePersonalEmailAccounts', True),
'6.5.1': ('exchange', 'enableModernAuthExchange', True),
'6.5.2': ('exchange', 'enableMailTips', True),
'6.5.3': ('exchange', 'restrictAdditionalStorageProviders', True),
'6.5.4': ('exchange', 'disableSMTPAuth', True),
'6.5.5': ('exchange', 'rejectDirectSend', True),
'1.2.2': ('exchange', 'blockSharedMailboxSignIn', True),
'2.1.12': ('exchange', 'connectionFilterIPAllowListEmpty', True),
'2.1.13': ('exchange', 'connectionFilterSafeListOff', True),
'2.1.14': ('exchange', 'inboundAntiSpamNoAllowedDomains', True),
'2.1.15': ('exchange', 'outboundAntiSpamLimits', True),
# --- Section 7: SharePoint ---
'7.2.1': ('sharePoint', 'requireModernAuthSharePoint', True),
'7.2.2': ('sharePoint', 'enableAADB2BIntegration', True),
'7.2.3': ('sharePoint', 'sharePointExternalSharing', 'Disabled'),
'7.2.4': ('sharePoint', 'oneDriveExternalSharing', 'Disabled'),
'7.2.5': ('sharePoint', 'preventGuestResharing', True),
'7.2.6': ('sharePoint', 'restrictSharePointExternalSharing', True),
'7.2.7': ('sharePoint', 'restrictLinkSharing', True),
'7.2.8': ('sharePoint', 'restrictSharingBySecurityGroup', True),
'7.2.9': ('sharePoint', 'guestAccessExpirationDays', 30),
'7.2.10': ('sharePoint', 'restrictReauthenticationVerificationCode', True),
'7.2.11': ('sharePoint', 'defaultSharingLinkPermission', 'View'),
'7.3.1': ('sharePoint', 'disallowInfectedFileDownload', True),
# --- Section 8: Teams ---
'8.1.1': ('teams', 'restrictExternalFileSharing', True),
'8.1.2': ('teams', 'blockChannelEmail', True),
'8.2.1': ('teams', 'restrictExternalDomains', True),
'8.2.2': ('teams', 'disableUnmanagedUserCommunication', True),
'8.2.3': ('teams', 'blockExternalUserInitiation', True),
'8.2.4': ('teams', 'blockTrialTenantCommunication', True),
'8.5.1': ('teams', 'allowAnonymousUsersToJoinMeeting', False),
'8.5.2': ('teams', 'allowAnonymousUsersToStartMeeting', False),
'8.5.3': ('teams', 'orgOnlyBypassLobby', True),
'8.5.4': ('teams', 'dialInCantBypassLobby', True),
'8.5.5': ('teams', 'noAnonymousMeetingChat', True),
'8.5.6': ('teams', 'onlyOrganizersCanPresent', True),
'8.5.7': ('teams', 'noExternalControl', True),
'8.5.8': ('teams', 'externalMeetingChatOff', True),
'8.5.9': ('teams', 'meetingRecordingOffByDefault', True),
'8.6.1': ('teams', 'enableSecurityConcernsReporting', True),
# --- Section 9: Power BI ---
'9.1.1': ('powerBI', 'restrictGuestAccess', True),
'9.1.2': ('powerBI', 'restrictExternalInvitations', True),
'9.1.3': ('powerBI', 'restrictGuestContentAccess', True),
'9.1.4': ('powerBI', 'restrictPublishToWeb', True),
'9.1.5': ('powerBI', 'disableRPythonVisuals', True),
'9.1.6': ('powerBI', 'enableSensitivityLabels', True),
'9.1.7': ('powerBI', 'restrictShareableLinks', True),
'9.1.8': ('powerBI', 'restrictExternalDataSharing', True),
'9.1.9': ('powerBI', 'blockResourceKeyAuth', True),
'9.1.10': ('powerBI', 'restrictServicePrincipalAPIAccess', True),
'9.1.11': ('powerBI', 'blockServicePrincipalProfiles', True),
'9.1.12': ('powerBI', 'restrictServicePrincipalWorkspaceCreation', True),
# --- Section 3: Purview ---
'3.1.1': ('purview', 'enableAuditLogSearch', True),
}
# Defender policy mappings
defender_policies = {
'2.1.1': ('safeLinks', {
'name': 'SafeLinks-Default',
'enabled': True,
'trackClicks': True,
'allowClickThrough': False,
'scanUrls': True,
'enableForInternalSenders': True,
}),
'2.1.2': ('antiMalware', {
'name': 'AntiMalware-Default',
'enabled': True,
'enableInternalNotifications': True,
'fileTypes': ['ace', 'ani', 'app', 'docm', 'exe', 'jar', 'jnlp', 'msi', 'ps1', 'scr', 'vbs', 'wsf'],
}),
'2.1.3': ('antiMalware', {
'name': 'AntiMalware-InternalNotify',
'enabled': True,
'enableInternalNotifications': True,
}),
'2.1.4': ('safeAttachments', {
'name': 'SafeAttachments-Default',
'enabled': True,
'action': 'Block',
'quarantineMessages': True,
}),
'2.1.5': ('safeAttachments', {
'name': 'SafeAttachments-SPO-Teams',
'enabled': True,
'action': 'Block',
'enableForSharePoint': True,
'enableForTeams': True,
}),
'2.1.6': ('antiSpam', {
'name': 'AntiSpam-Notify-Admins',
'enabled': True,
'notifyAdmins': True,
}),
'2.1.7': ('antiPhish', {
'name': 'AntiPhish-Default',
'enabled': True,
'enableMailboxIntelligence': True,
'enableSpoofIntelligence': True,
'mailboxIntelligenceProtectionAction': 'Quarantine',
}),
'2.1.11': ('antiMalware', {
'name': 'AntiMalware-Comprehensive',
'enabled': True,
'enableFileFilter': True,
}),
'2.4.1': ('priorityAccount', {'enabled': True}),
'2.4.2': ('priorityAccount', {'strictProtection': True}),
'2.4.4': ('zap', {'enabledForTeams': True}),
}
# Draft YAML blocks for tenant-specific controls (commented out)
draft_blocks = {
'1.1.3': [
" # ASSESSMENT-ONLY: Report current global admin count; cannot auto-remediate",
" # assessment:",
" # control: \"1.1.3\"",
" # name: \"GlobalAdminCount\"",
" # minAdmins: 2",
" # maxAdmins: 4",
],
'1.1.4': [
" # ASSESSMENT-ONLY: Report admin license footprint; cannot auto-remediate",
" # assessment:",
" # control: \"1.1.4\"",
" # name: \"AdminLicenseFootprint\"",
" # allowedSkus: [\"AAD_PREMIUM_P2\", \"ENTERPRISEPACK\", \"SPE_E5\"]",
],
'1.2.1': [
" # ASSESSMENT-ONLY: Review public groups; cannot auto-remediate",
" # assessment:",
" # control: \"1.2.1\"",
" # name: \"PublicGroupReview\"",
" # visibilityFilter: \"Public\"",
],
'3.2.1': [
" # DRAFT: Uncomment and customize DLP policies for your environment",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Financial-Data\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"Exchange\"",
" # - type: \"SharePoint\"",
" # - type: \"OneDrive\"",
" # rules:",
" # - name: \"Detect-Credit-Cards\"",
" # sensitiveInfoTypes: [\"Credit Card Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
" # - name: \"CIS-DLP-PII\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Detect-SSN\"",
" # sensitiveInfoTypes: [\"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.2.2': [
" # DRAFT: Uncomment and customize Teams DLP policy",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Teams\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Teams-Detect-PII\"",
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.2.3': [
" # DRAFT: Uncomment and customize Copilot DLP policy",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Copilot\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Copilot-Detect-Sensitive\"",
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.3.1': [
" # DRAFT: Uncomment and customize sensitivity labels for your organization",
" # sensitivityLabels:",
" # - name: \"Internal\"",
" # displayName: \"Internal\"",
" # priority: 1",
" # enabled: true",
" # labelAction: \"Encrypt\"",
" # - name: \"Confidential\"",
" # displayName: \"Confidential\"",
" # priority: 2",
" # enabled: true",
" # labelAction: \"Encrypt\"",
" # sensitivityLabelPolicies:",
" # - name: \"CIS-Label-Policy-Default\"",
" # enabled: true",
" # labels: [\"Internal\", \"Confidential\"]",
" # defaultLabel: \"Internal\"",
],
}
def format_val(val):
if isinstance(val, str):
return f'"{val}"'
elif isinstance(val, bool):
return str(val).lower()
elif isinstance(val, list):
return '[' + ', '.join(f'"{v}"' for v in val) + ']'
return str(val)
def write_simple_section(sec_num, sec_name, sec_recs):
if not sec_recs:
return
lines.append("")
lines.append(f" # ===============================================================")
lines.append(f" # Section {sec_num}: {sec_name}")
lines.append(f" # ===============================================================")
lines.append(f" {sec_name}:")
for r in sec_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
# Filter by level/license
if not matches_filter(profiles, level_filter, license_filter):
continue
# Skip CA policies — they are handled in the conditionalAccess section
if ctrl.startswith('5.2.2.'):
continue
# Skip on-prem AD password protection — hybrid only
if ctrl == '5.2.3.3':
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # NOTE: Hybrid-only control — requires on-premises Active Directory")
continue
# Banned passwords — add inline with external file support
if ctrl == '5.2.3.2':
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" # Option A: Inline list")
lines.append(f" bannedPasswords:")
lines.append(f" - \"Contoso\"")
lines.append(f" - \"Password\"")
lines.append(f" - \"Welcome\"")
lines.append(f" - \"Admin\"")
lines.append(f" - \"Login\"")
lines.append(f" - \"Microsoft\"")
lines.append(f" - \"Office365\"")
lines.append(f" # Option B: External file (one password per line)")
lines.append(f" # bannedPasswordsFile: \"./banned-passwords.txt\"")
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
rem = r.get('remediation', '')
hint = rem[:120] + '...' if len(rem) > 120 else rem
if hint:
lines.append(f" # HINT: {hint}")
lines.append(f" # TODO: Implement manually per PDF instructions")
continue
if ctrl in simple_mappings:
sec, key, val = simple_mappings[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {key}: {format_val(val)}")
elif ctrl in draft_blocks:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
for line in draft_blocks[ctrl]:
lines.append(line)
else:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
# Write non-defender, non-CA sections
for sec_num in ['1', '5', '6', '7', '8', '9', '3']:
sec_name = section_names[sec_num]
sec_recs = [r for r in recommendations if r['control'].split('.')[0] == sec_num]
write_simple_section(sec_num, sec_name, sec_recs)
# Defender section (with proper policy structures)
def_recs = [r for r in recommendations if r['control'].split('.')[0] == '2']
if def_recs:
lines.append("")
lines.append(" # ===============================================================")
lines.append(" # Section 2: Defender for Office 365")
lines.append(" # ===============================================================")
lines.append(" defender:")
for r in def_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
if not matches_filter(profiles, level_filter, license_filter):
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
continue
if ctrl in defender_policies:
policy_type, policy_def = defender_policies[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {policy_type}:")
for k, v in policy_def.items():
lines.append(f" {k}: {format_val(v)}")
elif ctrl in ['2.1.8', '2.1.9', '2.1.10']:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # NOTE: DNS-level control — configure via DNS provider, not M365 tenant")
elif ctrl in simple_mappings:
sec, key, val = simple_mappings[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {key}: {format_val(val)}")
else:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
# Conditional Access section
ca_recs = [r for r in recommendations if r['control'].startswith('5.2.2.')]
if ca_recs:
lines.append("")
lines.append(" # ===============================================================")
lines.append(" # Section 5.2.2: Conditional Access")
lines.append(" # ===============================================================")
lines.append(" conditionalAccess:")
lines.append(" reportOnly: true")
lines.append(" breakGlassGroup: \"CIS-BreakGlass\"")
lines.append(" policies:")
for r in ca_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
if not matches_filter(profiles, level_filter, license_filter):
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
continue
name = re.sub(r'[^a-zA-Z0-9\s]', '', title)
name = re.sub(r'\s+', '-', name)
name = re.sub(r'-+', '-', name)
name = name[:55].strip('-')
lines.append(f" - name: \"{name}\"")
lines.append(f" cisControl: \"{ctrl}\"")
lines.append(f" description: \"{title}\"")
lines.append(f" state: enabledForReportingButNotEnforced")
lines.append(f" conditions:")
lines.append(f" applications:")
t = title.lower()
if 'intune enrollment' in t:
lines.append(f" includeApplications: [\"0000000a-0000-0000-c000-000000000000\"]")
elif 'register security' in t:
lines.append(f" includeUserActions: [\"urn:user:registersecurityinfo\"]")
else:
lines.append(f" includeApplications: [\"All\"]")
lines.append(f" users:")
if 'admin' in t or 'administrator' in t:
lines.append(f" includeRoles:")
lines.append(f" - \"Global Administrator\"")
lines.append(f" - \"Privileged Role Administrator\"")
lines.append(f" - \"Security Administrator\"")
lines.append(f" - \"Exchange Administrator\"")
lines.append(f" - \"SharePoint Administrator\"")
lines.append(f" - \"Conditional Access Administrator\"")
lines.append(f" - \"Application Administrator\"")
lines.append(f" - \"Cloud Application Administrator\"")
lines.append(f" - \"User Administrator\"")
lines.append(f" - \"Helpdesk Administrator\"")
lines.append(f" - \"Billing Administrator\"")
lines.append(f" - \"Authentication Administrator\"")
lines.append(f" - \"Password Administrator\"")
lines.append(f" - \"Global Reader\"")
else:
lines.append(f" includeUsers: [\"All\"]")
if 'legacy' in t:
lines.append(f" clientAppTypes: [\"exchangeActiveSync\", \"other\"]")
elif 'device code' in t:
lines.append(f" authenticationFlows:")
lines.append(f" deviceCodeFlow:")
lines.append(f" isEnabled: true")
elif 'sign-in risk' in t or 'risk' in t:
lines.append(f" signInRiskLevels: [\"medium\", \"high\"]")
elif 'named location' in t or 'geographic' in t:
lines.append(f" # TODO: Define named locations in Entra admin center")
lines.append(f" grantControls:")
if 'block' in t and ('legacy' in t or 'device code' in t or 'risk' in t or 'authentication transfer' in t):
lines.append(f" builtInControls: [\"block\"]")
lines.append(f" operator: \"OR\"")
elif 'mfa' in t and 'phishing-resistant' in t:
lines.append(f" builtInControls: [\"authenticationStrength\"]")
lines.append(f" authenticationStrength:")
lines.append(f" id: \"00000000-0000-0000-0000-000000000004\"")
lines.append(f" operator: \"OR\"")
elif 'mfa' in t or 'multifactor' in t or 'reauthentication' in t or 're-authentication' in t:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
elif 'managed device' in t:
lines.append(f" builtInControls: [\"compliantDevice\", \"domainJoinedDevice\"]")
lines.append(f" operator: \"OR\"")
elif 'token protection' in t:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
lines.append(f" # TODO: Enable Token Protection via Authentication Strength policy")
else:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
if 'sign-in frequency' in t or 'browser' in t or 'persistent' in t:
lines.append(f" sessionControls:")
lines.append(f" signInFrequency:")
lines.append(f" value: 12")
lines.append(f" type: hours")
lines.append(f" isEnabled: true")
lines.append(f" persistentBrowser:")
lines.append(f" mode: never")
lines.append(f" isEnabled: true")
return '\n'.join(lines) + '\n'
def main():
if len(sys.argv) < 3:
print("Usage: _ConvertFrom-CISPDF.py <pdf_path> <output_path> [prefix] [level] [license]")
print(" level: L1 | L2 | Both (default)")
print(" license: E3 | E5 | Both (default)")
sys.exit(1)
pdf_path = sys.argv[1]
output_path = sys.argv[2]
prefix = sys.argv[3] if len(sys.argv) > 3 else "CIS-v7-"
level_filter = sys.argv[4] if len(sys.argv) > 4 else "Both"
license_filter = sys.argv[5] if len(sys.argv) > 5 else "Both"
print(f"Parsing PDF: {pdf_path}")
recommendations = parse_pdf(pdf_path)
auto = sum(1 for r in recommendations if r['status'] == 'Automated')
manual = sum(1 for r in recommendations if r['status'] == 'Manual')
print(f"Found {len(recommendations)} unique recommendations")
print(f" Automated: {auto}")
print(f" Manual: {manual}")
print(f"\nGenerating YAML (level={level_filter}, license={license_filter})...")
yaml = generate_yaml(recommendations, prefix, level_filter, license_filter)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(yaml)
print(f"Written: {output_path}")
print(f"Total lines: {len(yaml.splitlines())}")
if __name__ == '__main__':
main()
+1104
View File
File diff suppressed because it is too large Load Diff
+606
View File
@@ -0,0 +1,606 @@
#requires -Version 5.1
<#
.SYNOPSIS
Unified launcher for the macOS Intune Toolkit.
.DESCRIPTION
Presents a single terminal UI to choose from all available
headless Intune management tools. Passes through common auth parameters.
Press Esc to go back to the menu from any selection.
.EXAMPLE
./Start-IntuneToolkit.ps1 -TenantId "contoso.onmicrosoft.com"
#>
[CmdletBinding()]
param(
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfHint
{
if(Test-FzfAvailable) { return }
Write-Host "`n[fzf not found]" -ForegroundColor Yellow -NoNewline
Write-Host " Install fzf for the best interactive menu experience.`n" -ForegroundColor DarkGray
if($IsMacOS)
{
Write-Host " macOS: brew install fzf" -ForegroundColor DarkGray
}
elseif($IsLinux)
{
Write-Host " Debian/Ubuntu: sudo apt install fzf" -ForegroundColor DarkGray
Write-Host " Fedora: sudo dnf install fzf" -ForegroundColor DarkGray
Write-Host " Arch: sudo pacman -S fzf" -ForegroundColor DarkGray
}
else
{
Write-Host " Windows: winget install junegunn.fzf" -ForegroundColor DarkGray
Write-Host " choco install fzf" -ForegroundColor DarkGray
}
Write-Host " (Falling back to numbered menus for now.)`n" -ForegroundColor DarkGray
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
$selected = $Items | fzf --header=$Header
if(-not $selected) { return $null }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
$choice = Read-Host "Enter a number (0 to exit)"
if($choice -eq "0") { return "EXIT" }
$index = [int]$choice - 1
if($index -ge 0 -and $index -lt $Items.Count)
{
return $Items[$index]
}
return $null
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header
}
return Show-NumberedMenu -Items $Items -Header $Header
}
#endregion
$projectRoot = $PSScriptRoot
#region Tenant selection
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
function Get-SavedTenants
{
param([string]$SettingsPath)
if(-not (Test-Path $SettingsPath)) { return @() }
try
{
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
$tenants = @()
foreach($key in $raw.Keys)
{
if($key -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')
{
$name = $null
if($raw[$key] -is [hashtable] -and $raw[$key].ContainsKey('TenantName'))
{
$name = $raw[$key]['TenantName']
}
elseif($raw[$key] -is [psobject] -and $raw[$key].PSObject.Properties['TenantName'])
{
$name = $raw[$key].TenantName
}
$display = if($name) { "$name ($key)" } else { $key }
$tenants += [PSCustomObject]@{ TenantId = $key; TenantName = $name; Display = $display }
}
}
return $tenants | Sort-Object Display
}
catch
{
return @()
}
}
function Update-TenantNameCache
{
param([string]$SettingsPath, [string]$TenantId, [string]$TenantName)
if(-not (Test-Path $SettingsPath)) { return }
try
{
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
if($raw[$TenantId] -is [hashtable])
{
$raw[$TenantId]['TenantName'] = $TenantName
}
else
{
$raw[$TenantId] = @{ TenantName = $TenantName }
}
$raw | ConvertTo-Json -Depth 10 | Set-Content -Path $SettingsPath -Force
}
catch { }
}
function Resolve-TenantName
{
param([string]$TenantId, [string]$SettingsPath)
$settingsObj = $null
try
{
$settingsObj = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
}
catch { return $null }
$tenantNode = $settingsObj[$TenantId]
if(-not $tenantNode) { return $null }
$appId = $tenantNode['GraphAzureAppId']
if(-not $appId) { return $null }
$secret = $tenantNode['GraphAzureAppSecret']
$cert = $tenantNode['GraphAzureAppCert']
if(-not $secret -and $IsMacOS)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $secret = $keychainSecret }
}
catch { }
}
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule)) { return $null }
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $SettingsPath
TenantId = $TenantId
AppId = $appId
AuthMode = "AppOnly"
}
if($secret) { $invokeParams.Secret = $secret }
elseif($cert) { $invokeParams.Certificate = $cert }
try
{
Import-Module $runtimeModule -Force | Out-Null
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams | Out-Null
if(Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue)
{
$org = Invoke-GraphRequest "/organization" -ErrorAction Stop
if($org.value -and $org.value[0].displayName)
{
return $org.value[0].displayName
}
}
}
catch { }
return $null
}
$settingsPath = $SettingsFile
if(-not $settingsPath) { $settingsPath = Get-DefaultSettingsPath }
if(-not $TenantId)
{
$tenants = Get-SavedTenants -SettingsPath $settingsPath
$tenantOptions = @()
foreach($t in $tenants)
{
$tenantOptions += $t.Display
}
$tenantOptions += "[+ Onboard new tenant]"
$tenantOptions += "[Exit]"
$selectedTenantDisplay = Select-MenuItem -Items $tenantOptions -Header "Select a tenant"
if(-not $selectedTenantDisplay -or $selectedTenantDisplay -eq "[Exit]" -or $selectedTenantDisplay -eq "EXIT")
{
exit 0
}
elseif($selectedTenantDisplay -eq "[+ Onboard new tenant]")
{
$TenantId = Read-Host "Enter the new Tenant ID (GUID)"
if(-not $TenantId)
{
Write-Host "No tenant ID provided. Exiting." -ForegroundColor Yellow
exit 0
}
$initPath = Join-Path $projectRoot "Scripts/Initialize-IntuneAuth.ps1"
& $initPath -TenantId $TenantId
Write-Host "`nOnboarding complete. Restarting launcher..." -ForegroundColor Green
Start-Sleep -Seconds 1
$restartParams = @{}
if($SettingsFile) { $restartParams.SettingsFile = $SettingsFile }
& $PSCommandPath @restartParams
exit 0
}
else
{
$TenantId = $selectedTenantDisplay -replace '.*\(([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\)$', '$1'
if(-not $TenantId)
{
$TenantId = $selectedTenantDisplay
}
}
}
$currentTenant = (Get-SavedTenants -SettingsPath $settingsPath) | Where-Object { $_.TenantId -eq $TenantId } | Select-Object -First 1
if(-not $currentTenant -or -not $currentTenant.TenantName)
{
Write-Host "`nResolving tenant name..." -ForegroundColor Cyan
$resolvedName = Resolve-TenantName -TenantId $TenantId -SettingsPath $settingsPath
if($resolvedName)
{
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $TenantId -TenantName $resolvedName
Write-Host "Cached tenant name: $resolvedName" -ForegroundColor Green
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $resolvedName; Display = "$resolvedName ($TenantId)" }
}
}
#endregion
Show-FzfHint
# Build common parameter hashtable
$commonParams = @{
TenantId = $TenantId
AppId = $AppId
Secret = $Secret
Certificate = $Certificate
AuthMode = $AuthMode
RedirectUri = $RedirectUri
SettingsFile = $SettingsFile
}
$menuItems = @(
# Export / Import
"1. Export policies"
"2. Import policies"
"7. Export assignments to CSV/Markdown"
"5. Backup assignments"
"6. Restore assignments"
# Bulk operations
"3. Bulk app assignment"
"4. Bulk assignment manager (policies)"
"8. Bulk rename policies"
"21. Bulk delete policies"
"9. Bulk device operations"
# Baselines & compliance
"10. Deploy baseline"
"11. Deploy baseline (dry-run / WhatIf)"
"17. Deploy CIS M365 baseline"
"19. Document Conditional Access policies"
# Reporting
"16. Generate reports"
"20. Export Entra role membership"
# Tenant & auth admin
"12. Initialize auth (one-time setup)"
"13. Refresh tenant names"
"18. Rotate app secret"
"14. Delete local tenant auth only"
"15. Delete tenant auth and app registration"
"0. Exit"
)
while($true)
{
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " macOS Intune Toolkit" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if($currentTenant -and $currentTenant.TenantName)
{
Write-Host " Tenant: $($currentTenant.TenantName) ($TenantId)" -ForegroundColor Green
}
else
{
Write-Host " Tenant: $TenantId" -ForegroundColor Green
}
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
$selection = Select-MenuItem -Items $menuItems -Header "Select a tool to launch"
if(-not $selection)
{
continue
}
if($selection -eq "EXIT" -or $selection -like "*0. Exit*")
{
Write-Host "`nExiting. Goodbye!" -ForegroundColor Yellow
exit 0
}
$choiceNumber = [int]($selection -replace "^(\d+)\..*$", '$1')
$script = $null
switch($choiceNumber)
{
1 { $script = "Scripts/Start-HeadlessIntune.ps1" }
2 { $script = "Scripts/Start-HeadlessIntune.ps1" }
3 { $script = "Scripts/Bulk-AppAssignment.ps1" }
4 { $script = "Scripts/Bulk-AssignmentManager.ps1" }
5 { $script = "Scripts/Backup-Restore-Assignments.ps1" }
6 { $script = "Scripts/Backup-Restore-Assignments.ps1" }
7 { $script = "Scripts/Export-AssignmentsToCsv.ps1" }
8 { $script = "Scripts/Bulk-RenamePolicies.ps1" }
21 { $script = "Scripts/Bulk-DeletePolicies.ps1" }
9 { $script = "Scripts/Bulk-DeviceOperations.ps1" }
10 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
11 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
12 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
14 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
15 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
18 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
default { }
}
# Clear any mode-specific params from previous loop iteration
$commonParams.Remove("Interactive")
$commonParams.Remove("Action")
$commonParams.Remove("Mode")
$commonParams.Remove("WhatIf")
switch($choiceNumber)
{
1 { $commonParams.Interactive = $true; $commonParams.Action = "Export" }
2 { $commonParams.Interactive = $true; $commonParams.Action = "Import" }
5 { $commonParams.Mode = "Backup" }
6 { $commonParams.Mode = "Restore" }
11 { $commonParams.WhatIf = $true }
}
if($choiceNumber -eq 13)
{
Write-Host "`nRefreshing tenant names..." -ForegroundColor Cyan
$tenantsToRefresh = Get-SavedTenants -SettingsPath $settingsPath
$refreshed = 0
$failed = 0
foreach($t in $tenantsToRefresh)
{
Write-Host " Resolving $($t.TenantId) ..." -ForegroundColor DarkGray -NoNewline
$name = Resolve-TenantName -TenantId $t.TenantId -SettingsPath $settingsPath
if($name)
{
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $t.TenantId -TenantName $name
Write-Host " -> $name" -ForegroundColor Green
$refreshed++
if($t.TenantId -eq $TenantId)
{
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $name; Display = "$name ($TenantId)" }
}
}
else
{
Write-Host " -> FAILED" -ForegroundColor Red
$failed++
}
}
Write-Host "`nRefresh complete. Success: $refreshed, Failed: $failed" -ForegroundColor Cyan
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if($choiceNumber -eq 16)
{
$reportTypes = @("Settings","Assignments","ObjectInventory","All")
$reportType = Select-MenuItem -Items $reportTypes -Header "Select report type"
if(-not $reportType) { continue }
$dataSource = Select-MenuItem -Items @("Use existing backup","Pull fresh data from tenant") -Header "Data source"
if(-not $dataSource) { continue }
$backupRoot = $null
$exportPath = $null
if($dataSource -like "*fresh*")
{
$exportPath = Read-Host "Export path (where to save fresh data)"
if([string]::IsNullOrWhiteSpace($exportPath)) { Write-Host "Required." -ForegroundColor Red; continue }
$backupRoot = $exportPath
}
else
{
$backupRoot = Read-Host "Backup root path"
if([string]::IsNullOrWhiteSpace($backupRoot)) { Write-Host "Required." -ForegroundColor Red; continue }
if(-not (Test-Path $backupRoot)) { Write-Host "Path not found: $backupRoot" -ForegroundColor Red; continue }
}
$outputDir = Read-Host "Output directory for reports"
if([string]::IsNullOrWhiteSpace($outputDir)) { Write-Host "Required." -ForegroundColor Red; continue }
$includeAssignments = $false
if($reportType -in @("Settings","All"))
{
$ans = Read-Host "Include assignment columns in settings report? [y/N]"
$includeAssignments = $ans -like 'y*'
}
$headlessScript = Join-Path $projectRoot "Scripts/Start-HeadlessIntune.ps1"
if($dataSource -like "*fresh*")
{
Write-Host "`nExporting policies from tenant $TenantId ..." -ForegroundColor Cyan
$exportParams = @{ Action = "Export"; TenantId = $TenantId; ExportPath = $exportPath; IncludeAssignments = $true; AuthMode = $AuthMode }
if($AppId) { $exportParams.AppId = $AppId }
if($Secret) { $exportParams.Secret = $Secret }
elseif($Certificate) { $exportParams.Certificate = $Certificate }
if($SettingsFile) { $exportParams.SettingsFile = $SettingsFile }
& $headlessScript @exportParams
}
$genParams = @{ Action = "GenerateReports"; ReportType = $reportType; BackupRoot = $backupRoot; OutputDir = $outputDir }
if($includeAssignments) { $genParams.IncludeAssignmentsInSettings = $true }
& $headlessScript @genParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if($choiceNumber -eq 17)
{
$defaultBaseline = Join-Path $projectRoot "Baselines/CISM365-v7-Generated.yaml"
$baselinePath = Read-Host "Baseline YAML path (default: $defaultBaseline)"
if([string]::IsNullOrWhiteSpace($baselinePath)) { $baselinePath = $defaultBaseline }
if(-not (Test-Path $baselinePath)) { Write-Host "Not found: $baselinePath" -ForegroundColor Red; continue }
$cisMode = Select-MenuItem -Items @("Assess","Deploy") -Header "Select mode"
if(-not $cisMode) { continue }
$apply = $false
if($cisMode -eq "Deploy")
{
$ans = Read-Host "Apply changes? [y/N]"
$apply = $ans -like 'y*'
}
$allWorkloads = @("EntraID","ConditionalAccess","Exchange","SharePoint","Teams","PowerBI","Defender","Purview")
$workloadStr = Read-Host "Workloads (comma-separated, or Enter for all)"
$workloads = if([string]::IsNullOrWhiteSpace($workloadStr)) { $allWorkloads } else { $workloadStr -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } }
$cisScript = Join-Path $projectRoot "Scripts/Deploy-CISM365Baseline.ps1"
$cisParams = @{ BaselinePath = $baselinePath; TenantId = $TenantId; Mode = $cisMode; AuthMode = $AuthMode; Workloads = $workloads }
if($apply) { $cisParams.Apply = $true }
if($AppId) { $cisParams.AppId = $AppId }
if($Secret) { $cisParams.Secret = $Secret }
elseif($Certificate) { $cisParams.Certificate = $Certificate }
& $cisScript @cisParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if($choiceNumber -eq 20)
{
$roleScript = Join-Path $projectRoot "Scripts/Export-EntraRoleMembership.ps1"
$roleParams = @{ TenantId = $TenantId; AuthMode = $AuthMode }
if($AppId) { $roleParams.AppId = $AppId }
if($Secret) { $roleParams.Secret = $Secret }
elseif($Certificate) { $roleParams.Certificate = $Certificate }
if($SettingsFile) { $roleParams.SettingsFile = $SettingsFile }
$csvOut = Read-Host "CSV output path (Enter for default: <TenantName>-Entra-AllRoleMemberships.csv)"
if(-not [string]::IsNullOrWhiteSpace($csvOut)) { $roleParams.CsvPath = $csvOut }
& $roleScript @roleParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if($choiceNumber -eq 19)
{
$caScript = Join-Path $projectRoot "Scripts/Invoke-ConditionalAccessDocumentation.ps1"
$ans = Read-Host "Export to Excel as well? [y/N]"
$doExcel = $ans -like 'y*'
$caParams = @{}
if($doExcel)
{
$caParams.ExportExcel = $true
$xlPath = Read-Host "Excel output path (Enter for default: Scripts/ConditionalAccessDocumentation.xlsx)"
if(-not [string]::IsNullOrWhiteSpace($xlPath)) { $caParams.ExcelPath = $xlPath }
}
& $caScript @caParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if(-not $script)
{
continue
}
$scriptPath = Join-Path $projectRoot $script
if(-not (Test-Path $scriptPath))
{
throw "Script not found: $scriptPath"
}
Write-Host "`nLaunching $script ...`n" -ForegroundColor Green
# Clone params and sanitize for scripts that don't accept the full auth set
$launchParams = $commonParams.Clone()
if($script -eq "Scripts/Initialize-IntuneAuth.ps1")
{
@("AppId","Secret","Certificate","AuthMode","RedirectUri","Interactive","Mode","WhatIf") | ForEach-Object { $launchParams.Remove($_) }
}
if($choiceNumber -eq 14)
{
$launchParams.Delete = $true
}
if($choiceNumber -eq 15)
{
$launchParams.DeleteApp = $true
}
if($choiceNumber -eq 18)
{
$launchParams.RotateSecret = $true
}
# Execute in same process so TUI flows naturally
& $scriptPath @launchParams
if($choiceNumber -eq 14 -or $choiceNumber -eq 15)
{
Write-Host "`nTenant auth deleted. Exiting." -ForegroundColor Yellow
exit 0
}
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
+1
View File
@@ -0,0 +1 @@
4.4.1