release: v4.1.0 — restructure entry points, add CIS baselines, reporting tools and fzf hints
- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root; Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/ - Add AGENTS.md with project architecture, entry points, and security notes - Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts - Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport, Export-ObjectInventoryReport) and CA wizard helpers - Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath, and optimized group loading - Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry - Update Extensions for Settings Catalog definition auto-export - Update README with v4.1.0, new entry points and script catalog - Bump VERSION to 4.1.0 - Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports, Settings.json and IntuneManagement.log
This commit is contained in:
+24
-2
@@ -1,16 +1,38 @@
|
||||
# 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
|
||||
.gitignore
|
||||
IntuneManagement.log
|
||||
Exporting */
|
||||
*.backup/
|
||||
*.backup
|
||||
|
||||
# Graph metadata cache
|
||||
GraphMetaData.xml
|
||||
%LOCALAPPDATA%/
|
||||
*%LOCALAPPDATA%*
|
||||
CloudAPIPowerShellManagement/
|
||||
|
||||
# Local application settings (contains secrets on non-macOS platforms)
|
||||
Settings.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv-pdf/
|
||||
.venv/
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# 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. Settings Catalog names are resolved from `configurationSettings.json` (auto-exported with Settings Catalog). |
|
||||
| `Scripts/Export-Policies.ps1` | Export policies to JSON. |
|
||||
| `Scripts/Import-Policies.ps1` | Import policies from JSON. |
|
||||
| `Scripts/Initialize-IntuneAuth.ps1` | One-time Entra app setup; also supports `-RotateSecret`, `-Delete`, `-DeleteApp`. |
|
||||
| `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.
|
||||
@@ -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"
|
||||
@@ -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").
|
||||
@@ -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
|
||||
@@ -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 2–4 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
|
||||
@@ -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
|
||||
@@ -1,5 +1,20 @@
|
||||
# macOS Intune Toolkit Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -102,7 +102,10 @@ function Invoke-InitializeModule
|
||||
if(($global:OldAzureApps -is [Array] -and $currentAppID -in $global:OldAzureApps) -or (-not $currentAppID -and -not $customAppID))
|
||||
{
|
||||
$global:informOldAzureApp = $true
|
||||
Write-Log "Microsoft Intune PowerShell is being decomissioned. Please change to a supported app eg Microsoft Graph or a custom app!" 2
|
||||
if($global:hideUI -ne $true)
|
||||
{
|
||||
Write-Log "Microsoft Intune PowerShell is being decomissioned. Please change to a supported app eg Microsoft Graph or a custom app!" 2
|
||||
}
|
||||
}
|
||||
|
||||
$viewPanel = $null
|
||||
@@ -3616,6 +3619,40 @@ function Start-PostExportSettingsCatalog
|
||||
param($obj, $objectType, $path)
|
||||
|
||||
Add-EMAssignmentsToExportFile $obj $objectType $path
|
||||
|
||||
# Export the Settings Catalog setting definitions once per backup so the
|
||||
# settings report can resolve settingDefinitionId values to the display
|
||||
# names shown in the Intune portal.
|
||||
Start-ExportSettingsCatalogDefinitions -Path $path
|
||||
}
|
||||
|
||||
function Start-ExportSettingsCatalogDefinitions
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$rootPath = Split-Path -Parent $Path
|
||||
if([string]::IsNullOrWhiteSpace($rootPath)) { return }
|
||||
|
||||
$defFile = Join-Path $rootPath "configurationSettings.json"
|
||||
if(Test-Path $defFile) { return }
|
||||
|
||||
try
|
||||
{
|
||||
Write-Log "Exporting Settings Catalog definitions for report resolution -> $defFile"
|
||||
$definitions = Invoke-GraphRequest -Url "/deviceManagement/configurationSettings" -AllPages -ODataMetadata "none"
|
||||
if($definitions -and $definitions.value)
|
||||
{
|
||||
$definitions | ConvertTo-Json -Depth 20 | Out-File -FilePath $defFile -Encoding utf8 -Force
|
||||
Write-Log "Exported $($definitions.value.Count) Settings Catalog definitions"
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to export Settings Catalog definitions: $($_.Exception.Message)" 2
|
||||
}
|
||||
}
|
||||
|
||||
function Start-PreUpdateSettingsCatalog
|
||||
|
||||
+11
-5
@@ -551,15 +551,21 @@ function Invoke-GraphRequest
|
||||
}
|
||||
catch
|
||||
{
|
||||
$retryCount++
|
||||
if($NoError -eq $true) { return }
|
||||
if($_.Exception.Response.StatusCode -eq 429 -and $retryCount -le $retryMax)
|
||||
if($_.Exception.Response.StatusCode -eq 429 -and $retryCount -lt $retryMax)
|
||||
{
|
||||
# NOT OK - Should use the date property but could not replicate the issue
|
||||
$retryCount++
|
||||
$retryRequest = $true
|
||||
Write-Log "429 - Too many requests received. Wait 5 s before retry" 2
|
||||
Start-Sleep -Seconds 5
|
||||
$retryAfterSec = 5
|
||||
try
|
||||
{
|
||||
$raHeader = $_.Exception.Response.Headers['Retry-After']
|
||||
if($raHeader) { $retryAfterSec = [int]$raHeader }
|
||||
}
|
||||
catch { }
|
||||
$sleepSec = [Math]::Min($retryAfterSec * [Math]::Pow(2, $retryCount - 1), 120)
|
||||
Write-Log "429 - Too many requests. Retry $retryCount/$retryMax after $sleepSec s" 2
|
||||
Start-Sleep -Seconds $sleepSec
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -15,22 +15,23 @@ This repository is now CLI-first. The old WPF application surface has been remov
|
||||
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 ./Scripts/Start-IntuneToolkit.ps1
|
||||
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 ./Scripts/Start-IntuneToolkit.ps1 -TenantId "<tenant-id>"
|
||||
pwsh ./Start-IntuneToolkit.ps1 -TenantId "<tenant-id>"
|
||||
```
|
||||
|
||||
## Entry points
|
||||
|
||||
* [Scripts/Start-IntuneToolkit.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Start-IntuneToolkit.ps1) — unified launcher (recommended)
|
||||
* [Start-HeadlessIntune.ps1](/Users/avedelphina/Local/IntuneManagement/Start-HeadlessIntune.ps1) — single action wrapper with optional TUI
|
||||
* [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
|
||||
* [Headless/IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1)
|
||||
|
||||
## Runtime
|
||||
@@ -38,7 +39,7 @@ pwsh ./Scripts/Start-IntuneToolkit.ps1 -TenantId "<tenant-id>"
|
||||
* `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` and `Start-IntuneManagementTui.ps1`. Falls back to numbered menus if not installed.
|
||||
* `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`)
|
||||
@@ -110,7 +111,7 @@ pwsh ./Scripts/Import-Policies.ps1 `
|
||||
## Single action entry point
|
||||
|
||||
```powershell
|
||||
pwsh ./Start-HeadlessIntune.ps1 `
|
||||
pwsh ./Scripts/Start-HeadlessIntune.ps1 `
|
||||
-Action Export `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AppId "<app-id>" `
|
||||
@@ -119,7 +120,7 @@ pwsh ./Start-HeadlessIntune.ps1 `
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh ./Start-HeadlessIntune.ps1 `
|
||||
pwsh ./Scripts/Start-HeadlessIntune.ps1 `
|
||||
-Action Import `
|
||||
-TenantId "<target-tenant-id>" `
|
||||
-AppId "<app-id>" `
|
||||
@@ -129,7 +130,7 @@ pwsh ./Start-HeadlessIntune.ps1 `
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh ./Start-HeadlessIntune.ps1 `
|
||||
pwsh ./Scripts/Start-HeadlessIntune.ps1 `
|
||||
-Action Export `
|
||||
-TenantId "<source-tenant-id>" `
|
||||
-AuthMode Browser `
|
||||
@@ -140,11 +141,15 @@ pwsh ./Start-HeadlessIntune.ps1 `
|
||||
## 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.
|
||||
* **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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ param(
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[switch]$WhatIf
|
||||
[switch]$WhatIf,
|
||||
|
||||
[string]$ReportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -375,11 +377,13 @@ if($effectiveWhatIf) { Write-Host "*** DRY-RUN MODE ENABLED ***" -ForegroundColo
|
||||
|
||||
#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 "`nResolving groups..." -ForegroundColor Cyan
|
||||
$existingGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
|
||||
$existingGroups = $existingGroupsResp.value
|
||||
Write-Host "Resolving baseline groups..." -ForegroundColor Cyan
|
||||
$existingGroups = $allGroupsData
|
||||
|
||||
foreach($grpDef in $baseline["groups"])
|
||||
{
|
||||
@@ -412,9 +416,7 @@ if($baseline.ContainsKey("groups") -and $baseline["groups"])
|
||||
#endregion
|
||||
|
||||
#region Pre-load all existing groups for assignment resolution
|
||||
Write-Host "`nPre-loading group directory..." -ForegroundColor Cyan
|
||||
$allGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
|
||||
foreach($g in $allGroupsResp.value)
|
||||
foreach($g in $allGroupsData)
|
||||
{
|
||||
if(-not $groupCache.ContainsKey($g.displayName))
|
||||
{
|
||||
@@ -432,6 +434,7 @@ $stats = @{
|
||||
Failed = 0
|
||||
Assigned = 0
|
||||
}
|
||||
$policyResults = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
{
|
||||
@@ -482,6 +485,9 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
$objectId = $null
|
||||
$shouldAssign = $false
|
||||
|
||||
$outcomeStatus = $null
|
||||
$outcomeObjectId = $null
|
||||
|
||||
if($existingObj)
|
||||
{
|
||||
Write-Host " Existing object found: $($existingObj.id)" -ForegroundColor Yellow
|
||||
@@ -495,22 +501,49 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
$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
|
||||
@@ -521,6 +554,7 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
$objectId = "WHATIF-NEW"
|
||||
$shouldAssign = $true
|
||||
$stats.Created++
|
||||
$outcomeStatus = "WhatIf-Create"
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -530,6 +564,7 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
Write-Host " Created: $objectId" -ForegroundColor Green
|
||||
$shouldAssign = $true
|
||||
$stats.Created++
|
||||
$outcomeStatus = "Created"; $outcomeObjectId = $newObj.id
|
||||
|
||||
# Secondary settings upload (EndpointSecurity / DeviceManagementIntents)
|
||||
if($typeMeta.SettingsAPI)
|
||||
@@ -556,11 +591,28 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,4 +631,49 @@ 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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
|
||||
|
||||
Covers Settings Catalog policies (human-readable names resolved from
|
||||
configurationSettings.json when present) and flat Device Configuration /
|
||||
Compliance Policy objects.
|
||||
|
||||
Output columns: Policy, Setting, Value
|
||||
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
||||
Group names resolved from MigrationTable.json (created by IntuneManagement export).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
OUTPUT_FILE = "settings-report.csv"
|
||||
|
||||
BASE_FIELDNAMES = ["Policy", "Setting", "Value"]
|
||||
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
|
||||
|
||||
_SKIP_KEYS = {
|
||||
"@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version",
|
||||
"displayName", "description", "roleScopeTagIds", "scheduledActionsForRule",
|
||||
"assignments", "deviceStatusOverview", "userStatusOverview",
|
||||
"deviceStatuses", "userStatuses", "deviceManagementApplicabilityRuleOsEdition",
|
||||
"deviceManagementApplicabilityRuleOsVersion", "deviceManagementApplicabilityRuleDeviceMode",
|
||||
"supportsScopeTags", "settingCount", "priorityMetaData", "creationSource",
|
||||
"templateReference", "name", "platforms", "technologies",
|
||||
}
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--root", required=True,
|
||||
help="Path to backup root containing 'Settings Catalog', "
|
||||
"'Device Configurations', etc.")
|
||||
p.add_argument("--output", default=OUTPUT_FILE,
|
||||
help=f"Output CSV file path (default: {OUTPUT_FILE})")
|
||||
p.add_argument("--include-assignments", action="store_true",
|
||||
help="Append AssignmentState, IncludeTargets, ExcludeTargets columns. "
|
||||
"Group names resolved from MigrationTable.json when present.")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog lookup (Settings Catalog human-readable names)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_catalog(root: Path) -> dict[str, Any]:
|
||||
path = root / "configurationSettings.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
with path.open(encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
entries = raw.get("value", raw) if isinstance(raw, dict) else raw
|
||||
return {e["id"]: e for e in entries if "id" in e}
|
||||
|
||||
|
||||
def _setting_name(catalog: dict[str, Any], setting_id: str) -> str:
|
||||
defn = catalog.get(setting_id)
|
||||
if defn:
|
||||
return defn.get("displayName") or defn.get("name") or setting_id
|
||||
tail = setting_id.rsplit("_", 1)[-1]
|
||||
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", tail).title()
|
||||
|
||||
|
||||
def _choice_label(catalog: dict[str, Any], setting_id: str, value_id: str) -> str:
|
||||
defn = catalog.get(setting_id)
|
||||
if defn:
|
||||
for opt in defn.get("options", []):
|
||||
if opt.get("itemId") == value_id:
|
||||
return opt.get("displayName") or value_id
|
||||
suffix = value_id.removeprefix(setting_id).lstrip("_")
|
||||
if suffix == "1":
|
||||
return "Enabled"
|
||||
if suffix == "0":
|
||||
return "Disabled"
|
||||
return suffix.title() if suffix.islower() else suffix or value_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assignment resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_groups(root: Path) -> dict[str, str]:
|
||||
"""Return groupId → displayName from MigrationTable.json (created by IntuneManagement export)."""
|
||||
path = root / "MigrationTable.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
obj["Id"]: obj["DisplayName"]
|
||||
for obj in data.get("Objects", [])
|
||||
if obj.get("Type") == "Group" and obj.get("Id") and obj.get("DisplayName")
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_target(target: dict, groups: dict[str, str]) -> tuple[str, str]:
|
||||
"""Returns (intent, display_name)."""
|
||||
ttype = target.get("@odata.type", "")
|
||||
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
||||
return "include", "All devices"
|
||||
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
|
||||
return "include", "All users"
|
||||
gid = target.get("groupId", "")
|
||||
name = (groups.get(gid)
|
||||
or target.get("groupDisplayName")
|
||||
or target.get("groupName")
|
||||
or gid
|
||||
or "Unresolved group")
|
||||
if ttype == "#microsoft.graph.exclusionGroupAssignmentTarget":
|
||||
return "exclude", name
|
||||
return "include", name
|
||||
|
||||
|
||||
def _summarize_assignments(policy: dict, groups: dict[str, str]) -> dict[str, str]:
|
||||
assignments = policy.get("assignments")
|
||||
if not isinstance(assignments, list):
|
||||
return {"AssignmentState": "NotExported", "IncludeTargets": "", "ExcludeTargets": ""}
|
||||
if not assignments:
|
||||
return {"AssignmentState": "Unassigned", "IncludeTargets": "", "ExcludeTargets": ""}
|
||||
|
||||
include: list[str] = []
|
||||
exclude: list[str] = []
|
||||
for item in assignments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
target = item.get("target") or {}
|
||||
intent, name = _resolve_target(target, groups)
|
||||
if str(item.get("intent", "")).lower() == "exclude" or intent == "exclude":
|
||||
exclude.append(name)
|
||||
else:
|
||||
include.append(name)
|
||||
|
||||
return {
|
||||
"AssignmentState": "Assigned",
|
||||
"IncludeTargets": "; ".join(sorted(set(include))),
|
||||
"ExcludeTargets": "; ".join(sorted(set(exclude))),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings Catalog recursive walker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
parent: str = "") -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
otype = si.get("@odata.type", "")
|
||||
sid = si.get("settingDefinitionId", "")
|
||||
name = _setting_name(catalog, sid)
|
||||
if parent:
|
||||
name = f"{parent} > {name}"
|
||||
|
||||
children: list[dict] = []
|
||||
|
||||
if "ChoiceSettingInstance" in otype and "Collection" not in otype:
|
||||
csv_val = si.get("choiceSettingValue", {})
|
||||
value = _choice_label(catalog, sid, csv_val.get("value", ""))
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": value})
|
||||
children = csv_val.get("children", [])
|
||||
|
||||
elif "SimpleSettingInstance" in otype and "Collection" not in otype:
|
||||
raw = si.get("simpleSettingValue", {})
|
||||
value = str(raw.get("value", "")) if isinstance(raw, dict) else str(raw)
|
||||
if value:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": value})
|
||||
|
||||
elif "SimpleSettingCollectionInstance" in otype:
|
||||
vals = [
|
||||
str(v.get("value", "")) if isinstance(v, dict) else str(v)
|
||||
for v in si.get("simpleSettingCollectionValue", [])
|
||||
]
|
||||
if vals:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": "; ".join(vals)})
|
||||
|
||||
elif "ChoiceSettingCollectionInstance" in otype:
|
||||
items = si.get("choiceSettingCollectionValue", [])
|
||||
vals = [_choice_label(catalog, sid, item.get("value", ""))
|
||||
for item in items if isinstance(item, dict)]
|
||||
if vals:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": "; ".join(vals)})
|
||||
|
||||
elif "GroupSettingCollectionInstance" in otype:
|
||||
for group in si.get("groupSettingCollectionValue", []):
|
||||
children.extend(group.get("children", []))
|
||||
|
||||
for child in children:
|
||||
if isinstance(child, dict):
|
||||
rows.extend(_walk(child, catalog, policy, parent=name))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Processors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
|
||||
for name in candidates:
|
||||
p = root / name
|
||||
if p.is_dir():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def process_settings_catalog(root: Path, catalog: dict[str, Any],
|
||||
groups: dict[str, str],
|
||||
include_assignments: bool) -> list[dict]:
|
||||
folder = _resolve_folder(root, "SettingsCatalog", "Settings Catalog")
|
||||
rows: list[dict] = []
|
||||
if folder is None:
|
||||
return rows
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy = json.load(f)
|
||||
policy_name = policy.get("name") or path.stem
|
||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||
for setting in policy.get("settings", []):
|
||||
si = setting.get("settingInstance", {})
|
||||
for row in _walk(si, catalog, policy_name):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def process_flat_category(root: Path, category: str,
|
||||
groups: dict[str, str],
|
||||
include_assignments: bool,
|
||||
*aliases: str) -> list[dict]:
|
||||
folder = _resolve_folder(root, category, *aliases)
|
||||
if folder is None:
|
||||
return []
|
||||
if (folder / "Policies").is_dir():
|
||||
folder = folder / "Policies"
|
||||
rows: list[dict] = []
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy = json.load(f)
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
policy_name = policy.get("displayName") or policy.get("name") or path.stem
|
||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||
for key, value in policy.items():
|
||||
if key in _SKIP_KEYS or value is None:
|
||||
continue
|
||||
if isinstance(value, (dict, list)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
if len(value_str) > 500:
|
||||
value_str = value_str[:497] + "..."
|
||||
else:
|
||||
value_str = str(value)
|
||||
row = {"Policy": policy_name, "Setting": key, "Value": value_str}
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = Path(args.root)
|
||||
out_path = Path(args.output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
include_assignments: bool = args.include_assignments
|
||||
fieldnames = BASE_FIELDNAMES + (ASSIGNMENT_FIELDNAMES if include_assignments else [])
|
||||
|
||||
catalog = _load_catalog(root)
|
||||
groups = _load_groups(root) if include_assignments else {}
|
||||
|
||||
rows: list[dict] = []
|
||||
rows.extend(process_settings_catalog(root, catalog, groups, include_assignments))
|
||||
rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments,
|
||||
"Device Configuration", "Device Configurations"))
|
||||
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
|
||||
"Compliance Policies"))
|
||||
rows.extend(process_flat_category(root, "CompliancePoliciesV2", groups, include_assignments,
|
||||
"Compliance Policies - V2"))
|
||||
rows.extend(process_flat_category(root, "EndpointSecurity", groups, include_assignments,
|
||||
"Endpoint Security"))
|
||||
rows.extend(process_flat_category(root, "AdministrativeTemplates", groups, include_assignments,
|
||||
"Administrative Templates"))
|
||||
|
||||
for row in rows:
|
||||
for col in fieldnames:
|
||||
v = row.get(col, "")
|
||||
if isinstance(v, str) and ("\n" in v or "\r" in v):
|
||||
row[col] = v.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
|
||||
|
||||
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
print(f"Written {len(rows)} rows → {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -33,6 +33,14 @@ 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(
|
||||
@@ -46,7 +54,12 @@ param(
|
||||
|
||||
[switch]$Delete,
|
||||
|
||||
[switch]$DeleteApp
|
||||
[switch]$DeleteApp,
|
||||
|
||||
[switch]$RotateSecret,
|
||||
|
||||
[ValidateRange(1,5)]
|
||||
[int]$SecretExpiryYears = 1
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -145,6 +158,70 @@ if ($Delete)
|
||||
}
|
||||
#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)
|
||||
@@ -389,16 +466,17 @@ if ($sp)
|
||||
Write-Host "Creating client secret..." -ForegroundColor Cyan
|
||||
$passwordCred = @{
|
||||
displayName = "IntuneManagementSecret"
|
||||
endDateTime = (Get-Date).AddYears(1)
|
||||
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 $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)
|
||||
{
|
||||
@@ -426,7 +504,7 @@ if ($IsMacOS)
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Secret : $($secret.SecretText)"
|
||||
Write-Host "Secret : <stored in $SettingsFile>"
|
||||
}
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
|
||||
|
||||
@@ -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,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
|
||||
+155
-4
@@ -113,7 +113,7 @@ function Read-YesNo
|
||||
$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"
|
||||
return $response -like 'y*'
|
||||
}
|
||||
|
||||
function Get-DefaultSettingsPath
|
||||
@@ -129,7 +129,7 @@ function Get-DefaultSettingsPath
|
||||
#endregion
|
||||
|
||||
#region Load defaults
|
||||
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
|
||||
$modulePath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Headless/IntuneManagement.Headless.psd1"
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
$defaultTypes = Get-DefaultIntunePolicyObjectTypes
|
||||
@@ -157,9 +157,160 @@ while($true)
|
||||
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
|
||||
|
||||
# 1. Action
|
||||
$action = Select-MenuItem -Items @("Export","Import") -Header "Select action"
|
||||
$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
|
||||
$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*")
|
||||
{
|
||||
$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 = Read-Host "Export path (where to save fresh data)"
|
||||
if([string]::IsNullOrWhiteSpace($exportPath)) { Write-Host "Export path is required." -ForegroundColor Red; continue }
|
||||
$backupRoot = $exportPath
|
||||
}
|
||||
else
|
||||
{
|
||||
$backupRoot = Read-Host "Backup root path (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 = Read-Host "Enter 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
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
@@ -182,7 +333,7 @@ $nameFilter = Read-Host "Name filter regex (optional, e.g. '^Win-OIB-')"
|
||||
|
||||
# 6. Name Mutation
|
||||
$nameSearchPattern = Read-Host "Name search regex for mutation (optional, e.g. '^Win-OIB-')"
|
||||
$nameReplacePattern =
|
||||
$nameReplacePattern = $null
|
||||
if(-not [string]::IsNullOrWhiteSpace($nameSearchPattern))
|
||||
{
|
||||
$nameReplacePattern = Read-Host "Replacement string (e.g. 'Win-TEST-')"
|
||||
@@ -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
|
||||
@@ -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
|
||||
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
|
||||
@@ -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()
|
||||
Executable
+1104
File diff suppressed because it is too large
Load Diff
@@ -1,127 +0,0 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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,
|
||||
|
||||
[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
|
||||
)
|
||||
|
||||
$modulePath = Join-Path $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 $PSScriptRoot "Scripts/Start-IntuneManagementTui.ps1"
|
||||
if(Test-Path $tuiScript)
|
||||
{
|
||||
$tuiResult = & $tuiScript
|
||||
if(-not $tuiResult) { Write-Host "No selection made. Exiting." -ForegroundColor Yellow; exit 0 }
|
||||
foreach($prop in $tuiResult.PSObject.Properties)
|
||||
{
|
||||
if($prop.Value -ne $null -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"
|
||||
}
|
||||
}
|
||||
|
||||
$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
|
||||
@@ -7,7 +7,7 @@
|
||||
headless Intune management tools. Passes through common auth parameters.
|
||||
Press Esc to go back to the menu from any selection.
|
||||
.EXAMPLE
|
||||
./Scripts/Start-IntuneToolkit.ps1 -TenantId "contoso.onmicrosoft.com"
|
||||
./Start-IntuneToolkit.ps1 -TenantId "contoso.onmicrosoft.com"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -107,7 +107,7 @@ function Select-MenuItem
|
||||
}
|
||||
#endregion
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$projectRoot = $PSScriptRoot
|
||||
|
||||
#region Tenant selection
|
||||
function Get-DefaultSettingsPath
|
||||
@@ -265,7 +265,9 @@ if(-not $TenantId)
|
||||
& $initPath -TenantId $TenantId
|
||||
Write-Host "`nOnboarding complete. Restarting launcher..." -ForegroundColor Green
|
||||
Start-Sleep -Seconds 1
|
||||
& $PSCommandPath
|
||||
$restartParams = @{}
|
||||
if($SettingsFile) { $restartParams.SettingsFile = $SettingsFile }
|
||||
& $PSCommandPath @restartParams
|
||||
exit 0
|
||||
}
|
||||
else
|
||||
@@ -306,6 +308,9 @@ $commonParams = @{
|
||||
}
|
||||
|
||||
$menuItems = @(
|
||||
"18. Rotate app secret"
|
||||
"17. Deploy CIS M365 baseline"
|
||||
"16. Generate reports"
|
||||
"15. Delete tenant auth and app registration"
|
||||
"14. Delete local tenant auth only"
|
||||
"13. Refresh tenant names"
|
||||
@@ -353,24 +358,25 @@ while($true)
|
||||
|
||||
$choiceNumber = [int]($selection -replace "^(\d+)\..*$", '$1')
|
||||
|
||||
$script = $null
|
||||
switch($choiceNumber)
|
||||
{
|
||||
1 { $script = "Start-HeadlessIntune.ps1"; $commonParams.Interactive = $true }
|
||||
2 { $script = "Start-HeadlessIntune.ps1"; $commonParams.Interactive = $true }
|
||||
3 { $script = "Scripts/Bulk-AppAssignment.ps1" }
|
||||
4 { $script = "Scripts/Bulk-AssignmentManager.ps1" }
|
||||
5 { $script = "Scripts/Backup-Restore-Assignments.ps1"; $commonParams.Mode = "Backup" }
|
||||
6 { $script = "Scripts/Backup-Restore-Assignments.ps1"; $commonParams.Mode = "Restore" }
|
||||
7 { $script = "Scripts/Export-AssignmentsToCsv.ps1" }
|
||||
8 { $script = "Scripts/Bulk-RenamePolicies.ps1" }
|
||||
9 { $script = "Scripts/Bulk-DeviceOperations.ps1" }
|
||||
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" }
|
||||
9 { $script = "Scripts/Bulk-DeviceOperations.ps1" }
|
||||
10 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
|
||||
11 { $script = "Scripts/Deploy-IntuneBaseline.ps1"; $commonParams.WhatIf = $true }
|
||||
11 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
|
||||
12 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
|
||||
13 { $script = $null }
|
||||
14 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
|
||||
15 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
|
||||
default { continue }
|
||||
18 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
|
||||
default { }
|
||||
}
|
||||
|
||||
# Clear any mode-specific params from previous loop iteration
|
||||
@@ -419,6 +425,99 @@ while($true)
|
||||
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(-not $script)
|
||||
{
|
||||
continue
|
||||
}
|
||||
|
||||
$scriptPath = Join-Path $projectRoot $script
|
||||
if(-not (Test-Path $scriptPath))
|
||||
{
|
||||
@@ -444,6 +543,11 @@ while($true)
|
||||
$launchParams.DeleteApp = $true
|
||||
}
|
||||
|
||||
if($choiceNumber -eq 18)
|
||||
{
|
||||
$launchParams.RotateSecret = $true
|
||||
}
|
||||
|
||||
# Execute in same process so TUI flows naturally
|
||||
& $scriptPath @launchParams
|
||||
|
||||
Reference in New Issue
Block a user