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:
2026-06-14 15:24:42 +02:00
parent e333af978c
commit d3e0769799
30 changed files with 8711 additions and 175 deletions
+341
View File
@@ -0,0 +1,341 @@
baseline:
name: Generated-ConditionalAccess-Baseline
conflictResolution: Skip
whatIf: false
tenantConfig:
conditionalAccess:
reportOnly: false
breakGlassGroup: CQRE-BreakGlass
policies:
- name: CQRE-CA0901-AllUsers-AllApps-BlockLegacyAuth
description: Block all legacy authentication protocols
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
clientAppTypes:
- exchangeActiveSync
- other
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA1901-AllUsers-SecurityInfo-RequireTrustedLocation
description: Require trusted location or managed device to register security
info
state: enabled
conditions:
applications:
includeUserActions:
- urn:user:registersecurityinfo
users:
includeUsers:
- All
grantControls:
builtInControls:
- compliantDevice
- domainJoinedDevice
operator: OR
- name: CQRE-CA0902-AllUsers-AllApps-BlockUnsupportedPlatforms
description: Block sign-ins from unknown or unsupported device platforms
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
platforms:
includePlatforms:
- all
excludePlatforms:
- android
- iOS
- windows
- macOS
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA0903-AllUsers-AllApps-BlockDeviceCodeFlow
description: Block device-code authentication flow
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
authenticationFlows:
deviceCodeFlow:
isEnabled: true
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA1902-AllUsers-AllApps-RequireMFAUntrusted
description: Require MFA only from untrusted locations
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
locations:
includeLocations:
- All
excludeLocations:
- AllTrusted
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA1903-AllUsers-AllApps-RequireCompliantDevice
description: Require compliant or hybrid-joined device for all users
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
grantControls:
builtInControls:
- compliantDevice
- domainJoinedDevice
operator: OR
- name: CQRE-CA1904-AllUsers-AllApps-BlockUntrustedLocations
description: Block sign-ins from untrusted locations
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
locations:
includeLocations:
- All
excludeLocations:
- AllTrusted
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA0904-AllUsers-AllApps-RequireMFAForRiskySignIns
description: Require MFA for medium/high risk sign-ins
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
signInRiskLevels:
- medium
- high
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA0905-AllUsers-AllApps-ForcePasswordChangeHighRiskUsers
description: Force password change for high-risk users
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
userRiskLevels:
- high
grantControls:
builtInControls:
- passwordChange
operator: OR
- name: CQRE-CA0906-AllUsers-AllApps-BlockInsiderRisk
description: Block sessions flagged by Purview Insider Risk
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeUsers:
- All
insiderRiskLevels:
- elevated
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA2901-Admins-AllApps-RequireCompliantDevice
description: Administrators must use compliant or hybrid-joined devices
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeRoles: &id001
- Global Administrator
- Privileged Role Administrator
- Security Administrator
- Exchange Administrator
- SharePoint Administrator
- Conditional Access Administrator
- Application Administrator
- Cloud Application Administrator
- User Administrator
- Helpdesk Administrator
- Billing Administrator
- Authentication Administrator
- Password Administrator
grantControls:
builtInControls:
- compliantDevice
- domainJoinedDevice
operator: OR
- name: CQRE-CA2902-Admins-AllApps-BlockUntrustedLocations
description: Administrators can only sign in from trusted locations
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeRoles: *id001
locations:
includeLocations:
- All
excludeLocations:
- AllTrusted
grantControls:
builtInControls:
- block
operator: OR
- name: CQRE-CA2903-Admins-AllApps-NoPersistentSession
description: No persistent browser sessions for admins; re-auth every 12h
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeRoles: *id001
grantControls:
builtInControls:
- mfa
operator: OR
sessionControls:
signInFrequency:
value: 12
type: hours
isEnabled: true
persistentBrowser:
mode: never
isEnabled: true
- name: CQRE-CA3901-Guests-AllApps-RequireMFA
description: Require MFA for guest and external users
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeGuestsOrExternalUsers:
guestTypes:
- internalGuest
- b2bCollaborationGuest
- b2bCollaborationMember
- b2bDirectConnectUser
externalTenants:
membershipKind: all
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA3902-Guests-AllApps-RequireTermsOfUse
description: Require guests to accept terms of use
state: enabled
conditions:
applications:
includeApplications:
- All
users:
includeGuestsOrExternalUsers:
guestTypes:
- internalGuest
- b2bCollaborationGuest
- b2bCollaborationMember
- b2bDirectConnectUser
externalTenants:
membershipKind: all
grantControls:
builtInControls:
- termsOfUse
operator: OR
- name: CQRE-CA4901-AllUsers-O365-AppEnforcedRestrictions
description: Enforce application restrictions for Office 365
state: enabled
conditions:
applications:
includeApplications:
- Office365
users:
includeUsers:
- All
grantControls:
builtInControls:
- mfa
operator: OR
sessionControls:
applicationEnforcedRestrictions:
isEnabled: true
- name: CQRE-CA4902-AllUsers-AzureMgmt-RequireMFA
description: Require MFA for Azure management portal
state: enabled
conditions:
applications:
includeApplications:
- 797f4846-ba00-4fd7-ba43-dac1f8f63013
users:
includeUsers:
- All
grantControls:
builtInControls:
- mfa
operator: OR
- name: CQRE-CA4903-AllUsers-AdminPortals-RequireMFA
description: Require MFA for Microsoft admin portals
state: enabled
conditions:
applications:
includeApplications:
- 797f4846-ba00-4fd7-ba43-dac1f8f63013
- c44b4083-3bb0-49c1-b47d-974e53cbdf3c
- 1b730954-1685-4b74-9bfd-dac224a7b894
- 00000003-0000-0ff1-ce00-000000000000
- 00000003-0000-0000-c000-000000000000
- de8bc8b5-d9f9-48b1-a8ad-b748da725064
- 00000002-0000-0ff1-ce00-000000000000
- 66a88757-258c-4c72-893c-3e8bed4d6899
users:
includeUsers:
- All
grantControls:
builtInControls:
- mfa
operator: OR
+74
View File
@@ -0,0 +1,74 @@
#requires -Version 7.0
<#
.SYNOPSIS
Converts a CIS M365 Benchmark v7.0.0 PDF into a YAML baseline manifest.
.DESCRIPTION
Extracts text from the draft CIS PDF, parses recommendations, and generates
a CISM365-v7.yaml baseline file ready for Deploy-CISM365Baseline.ps1.
Prerequisites:
- Python 3 with pypdf installed (script will create venv if needed)
- The draft PDF at the specified path
.PARAMETER PdfPath
Path to the CIS M365 v7.0.0 draft PDF.
.PARAMETER OutputPath
Path for the generated YAML file. Defaults to ./Baselines/CISM365-v7-Generated.yaml
.PARAMETER Prefix
Optional naming prefix for all generated policies.
.EXAMPLE
./Scripts/ConvertFrom-CISPDF.ps1 -PdfPath ~/Downloads/DRAFT_CIS_Microsoft_365_Foundations_Benchmark_v7.0.0.pdf
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$PdfPath,
[Parameter()]
[string]$OutputPath = "$PSScriptRoot/../Baselines/CISM365-v7-Generated.yaml",
[Parameter()]
[string]$Prefix = "CIS-v7-",
[Parameter()]
[ValidateSet('L1','L2','Both')]
[string]$Level = 'Both',
[Parameter()]
[ValidateSet('E3','E5','Both')]
[string]$License = 'Both'
)
$ErrorActionPreference = 'Stop'
# Resolve paths
$pdfPathResolved = Resolve-Path $PdfPath | Select-Object -ExpandProperty Path
$outputPathResolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
# Ensure Python venv exists
$venvPath = "$PSScriptRoot/../.venv-pdf"
$pythonExe = "$venvPath/bin/python3"
if (-not (Test-Path $pythonExe)) {
Write-Host "Creating Python virtual environment..." -ForegroundColor Yellow
python3 -m venv $venvPath
& "$venvPath/bin/pip" install pypdf | Out-Null
}
$pyScript = "$PSScriptRoot/_ConvertFrom-CISPDF.py"
if (-not (Test-Path $pyScript)) {
throw "Python converter script not found: $pyScript"
}
Write-Host "Converting PDF to YAML baseline..." -ForegroundColor Cyan
& $pythonExe $pyScript $pdfPathResolved $outputPathResolved $Prefix $Level $License
if ($LASTEXITCODE -eq 0) {
Write-Host "Done. Review the generated file before deploying." -ForegroundColor Green
} else {
throw "PDF conversion failed."
}
File diff suppressed because it is too large Load Diff
+104 -7
View File
@@ -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
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Generate a policy assignment inventory CSV from Intune backup JSON files.
Walks every JSON file under the backup root and emits one row per assignment
target (or one row per unassigned/not-exported object).
Output columns: PolicyType, ObjectName, ObjectType, AssignmentState,
Intent, AssignmentTarget, TargetType, AssignmentFilter,
FilterType, SourceFile
"""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
from typing import Iterator
_GROUP_TARGET_TYPES = {
"#microsoft.graph.groupAssignmentTarget",
"#microsoft.graph.exclusionGroupAssignmentTarget",
}
_EXCLUDED_DIRS = {"reports", "__archive__"}
FIELDNAMES = [
"PolicyType",
"ObjectName",
"ObjectType",
"AssignmentState",
"Intent",
"AssignmentTarget",
"TargetType",
"AssignmentFilter",
"FilterType",
"SourceFile",
]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--root", required=True,
help="Path to backup root (e.g. tenant-state/intune).")
p.add_argument("--output", default="assignment-report.csv",
help="Output CSV path (default: assignment-report.csv).")
p.add_argument("--policy-type", action="append", default=[],
help="Filter to specific top-level folder names (repeat or comma-separate).")
return p.parse_args()
def _safe(value: object) -> str:
return "" if value is None else str(value).strip()
def _resolve_target(target: dict) -> tuple[str, str]:
"""Returns (display_name, target_type_short)."""
ttype = _safe(target.get("@odata.type"))
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
return "All devices", ttype
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
return "All users", ttype
if ttype in _GROUP_TARGET_TYPES:
name = (target.get("groupDisplayName") or target.get("groupName")
or target.get("groupId") or "Unresolved group")
return _safe(name), ttype
return (_safe(target.get("groupDisplayName") or target.get("displayName")
or target.get("id")) or "Unknown target", ttype)
def _infer_intent(assignment: dict, target_type: str) -> str:
if "exclusion" in target_type.lower():
return "Exclude"
explicit = _safe(assignment.get("intent")).lower()
if explicit in {"exclude"}:
return "Exclude"
return "Include"
def _iter_rows(root: Path, policy_type_filter: set[str]) -> Iterator[dict]:
for path in sorted(root.rglob("*.json")):
try:
rel = path.relative_to(root)
except ValueError:
continue
if any(part in _EXCLUDED_DIRS for part in rel.parts):
continue
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(payload, dict):
continue
policy_type = rel.parts[0] if rel.parts else ""
if policy_type_filter and policy_type.lower() not in policy_type_filter:
continue
object_name = (_safe(payload.get("displayName")) or _safe(payload.get("name"))
or path.stem.split("__")[0])
object_type = _safe(payload.get("@odata.type"))
source = rel.as_posix()
base = {
"PolicyType": policy_type,
"ObjectName": object_name,
"ObjectType": object_type,
"SourceFile": source,
}
assignments = payload.get("assignments")
if not isinstance(assignments, list):
yield {**base, "AssignmentState": "NotExported", "Intent": "",
"AssignmentTarget": "Not exported in backup", "TargetType": "",
"AssignmentFilter": "", "FilterType": ""}
continue
valid = [a for a in assignments if isinstance(a, dict)]
if not valid:
yield {**base, "AssignmentState": "Unassigned", "Intent": "",
"AssignmentTarget": "No assignments", "TargetType": "",
"AssignmentFilter": "", "FilterType": ""}
continue
for assignment in valid:
target = assignment.get("target") or {}
target_name, target_type = _resolve_target(target)
intent = _infer_intent(assignment, target_type)
yield {
**base,
"AssignmentState": "Assigned",
"Intent": intent,
"AssignmentTarget": target_name,
"TargetType": target_type,
"AssignmentFilter": _safe(target.get("deviceAndAppManagementAssignmentFilterId")),
"FilterType": _safe(target.get("deviceAndAppManagementAssignmentFilterType")),
}
def main() -> None:
args = parse_args()
root = Path(args.root).resolve()
out_path = Path(args.output)
if not root.exists():
raise SystemExit(f"Backup root not found: {root}")
policy_type_filter: set[str] = set()
for raw in args.policy_type:
for part in raw.split(","):
v = part.strip().lower()
if v:
policy_type_filter.add(v)
rows = sorted(
_iter_rows(root, policy_type_filter),
key=lambda r: (r["PolicyType"].lower(), r["ObjectName"].lower(),
r["AssignmentState"], r["Intent"].lower(),
r["AssignmentTarget"].lower()),
)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
print(f"Written {len(rows)} rows → {out_path}")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Generate an object inventory CSV from Intune backup JSON files.
One row per JSON object. Includes assignment summary columns.
Output columns: PolicyType, ObjectName, ObjectType, ObjectId, Description,
AssignmentState, AssignmentCount, IncludeTargets, ExcludeTargets,
SourceFile
"""
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
from typing import Iterator
_EXCLUDED_DIRS = {"reports", "__archive__"}
_GROUP_TARGET_TYPES = {
"#microsoft.graph.groupAssignmentTarget",
"#microsoft.graph.exclusionGroupAssignmentTarget",
}
FIELDNAMES = [
"PolicyType",
"ObjectName",
"ObjectType",
"ObjectId",
"Description",
"AssignmentState",
"AssignmentCount",
"IncludeTargets",
"ExcludeTargets",
"SourceFile",
]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--root", required=True,
help="Path to backup root (e.g. tenant-state/intune).")
p.add_argument("--output", default="object-inventory.csv",
help="Output CSV path (default: object-inventory.csv).")
return p.parse_args()
def _safe(value: object) -> str:
return "" if value is None else str(value).strip()
def _resolve_target_name(target: dict) -> tuple[str, str]:
"""Returns (intent, display_name)."""
ttype = _safe(target.get("@odata.type"))
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
return "include", "All devices"
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
return "include", "All users"
if ttype == "#microsoft.graph.exclusionGroupAssignmentTarget":
name = (_safe(target.get("groupDisplayName") or target.get("groupName")
or target.get("groupId")) or "Unresolved group")
return "exclude", name
if ttype in _GROUP_TARGET_TYPES:
name = (_safe(target.get("groupDisplayName") or target.get("groupName")
or target.get("groupId")) or "Unresolved group")
return "include", name
return "include", (_safe(target.get("groupDisplayName") or target.get("id"))
or "Unknown target")
def _summarize_assignments(payload: dict) -> dict[str, str]:
assignments = payload.get("assignments")
if not isinstance(assignments, list):
return {"AssignmentState": "NotExported", "AssignmentCount": "0",
"IncludeTargets": "", "ExcludeTargets": ""}
valid = [a for a in assignments if isinstance(a, dict)]
if not valid:
return {"AssignmentState": "Unassigned", "AssignmentCount": "0",
"IncludeTargets": "", "ExcludeTargets": ""}
include: list[str] = []
exclude: list[str] = []
for assignment in valid:
target = assignment.get("target") or {}
intent, name = _resolve_target_name(target)
explicit = _safe(assignment.get("intent")).lower()
if explicit == "exclude" or intent == "exclude":
exclude.append(name)
else:
include.append(name)
return {
"AssignmentState": "Assigned",
"AssignmentCount": str(len(valid)),
"IncludeTargets": "; ".join(sorted(set(include))),
"ExcludeTargets": "; ".join(sorted(set(exclude))),
}
def _iter_rows(root: Path) -> Iterator[dict]:
for path in sorted(root.rglob("*.json")):
try:
rel = path.relative_to(root)
except ValueError:
continue
if any(part in _EXCLUDED_DIRS for part in rel.parts):
continue
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(payload, dict):
continue
policy_type = rel.parts[0] if rel.parts else ""
object_name = (_safe(payload.get("displayName")) or _safe(payload.get("name"))
or path.stem.split("__")[0])
assignment_summary = _summarize_assignments(payload)
yield {
"PolicyType": policy_type,
"ObjectName": object_name,
"ObjectType": _safe(payload.get("@odata.type")),
"ObjectId": _safe(payload.get("id")),
"Description": _safe(payload.get("description")),
"SourceFile": rel.as_posix(),
**assignment_summary,
}
def main() -> None:
args = parse_args()
root = Path(args.root).resolve()
out_path = Path(args.output)
if not root.exists():
raise SystemExit(f"Backup root not found: {root}")
rows = sorted(
_iter_rows(root),
key=lambda r: (r["PolicyType"].lower(), r["ObjectName"].lower()),
)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
print(f"Written {len(rows)} rows → {out_path}")
if __name__ == "__main__":
main()
+311
View File
@@ -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()
+84 -6
View File
@@ -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
+165
View File
@@ -0,0 +1,165 @@
#requires -Version 7.0
<#
.SYNOPSIS
Deploy an Intune or CIS M365 baseline to multiple tenants from a CSV manifest.
.DESCRIPTION
Reads a CSV file with one row per tenant, invokes Deploy-IntuneBaseline.ps1 or
Deploy-CISM365Baseline.ps1 for each row, and aggregates all per-tenant reports
into a single combined CSV summary.
CSV columns (Deploy-IntuneBaseline mode):
TenantId, BaselinePath, AppId, Secret, Certificate, AuthMode, ConflictResolution, WhatIf
CSV columns (Deploy-CISM365Baseline mode):
TenantId, BaselinePath, AppId, Secret, Certificate, AuthMode, Mode, Workloads, WhatIf
All columns except TenantId and BaselinePath are optional.
.PARAMETER CsvPath
Path to the CSV manifest file.
.PARAMETER ScriptMode
Which deployment script to invoke per tenant: 'Intune' or 'CIS'. Default: Intune.
.PARAMETER OutputDir
Directory for per-tenant reports and the combined summary. Default: same directory as CsvPath.
.PARAMETER WhatIf
Propagates WhatIf to every tenant run, overriding the CSV column.
.EXAMPLE
./Scripts/Invoke-BaselineBatch.ps1 -CsvPath ./tenants.csv -ScriptMode Intune
.EXAMPLE
./Scripts/Invoke-BaselineBatch.ps1 -CsvPath ./tenants.csv -ScriptMode CIS -WhatIf
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$CsvPath,
[ValidateSet("Intune","CIS")]
[string]$ScriptMode = "Intune",
[string]$OutputDir,
[switch]$WhatIf
)
$ErrorActionPreference = "Stop"
$csvResolved = Resolve-Path $CsvPath | Select-Object -ExpandProperty Path
if (-not (Test-Path $csvResolved)) { throw "CSV not found: $CsvPath" }
$rows = Import-Csv -Path $csvResolved
if (-not $rows -or $rows.Count -eq 0) { throw "CSV is empty: $CsvPath" }
$scriptDir = Split-Path -Parent $PSScriptRoot
$intuneScript = Join-Path $scriptDir "Scripts/Deploy-IntuneBaseline.ps1"
$cisScript = Join-Path $scriptDir "Scripts/Deploy-CISM365Baseline.ps1"
$targetScript = if ($ScriptMode -eq "CIS") { $cisScript } else { $intuneScript }
if (-not (Test-Path $targetScript)) { throw "Deployment script not found: $targetScript" }
$resolvedOutputDir = if ($OutputDir) { $OutputDir } else { Split-Path -Parent $csvResolved }
if (-not (Test-Path $resolvedOutputDir)) { New-Item -ItemType Directory -Path $resolvedOutputDir | Out-Null }
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$batchSummary = [System.Collections.Generic.List[PSCustomObject]]::new()
$rowIndex = 0
foreach ($row in $rows)
{
$rowIndex++
$tenantId = $row.TenantId?.Trim()
$baselinePath = $row.BaselinePath?.Trim()
if ([string]::IsNullOrWhiteSpace($tenantId) -or [string]::IsNullOrWhiteSpace($baselinePath))
{
Write-Warning "Row $rowIndex skipped: TenantId or BaselinePath is empty."
$batchSummary.Add([PSCustomObject]@{
Row = $rowIndex
TenantId = $tenantId
Baseline = $baselinePath
Outcome = 'Skipped-InvalidRow'
ReportPath = $null
Error = 'TenantId or BaselinePath empty'
})
continue
}
$tenantReportPath = Join-Path $resolvedOutputDir "${tenantId}_${ts}.csv"
Write-Host ""
Write-Host "======================================================" -ForegroundColor Cyan
Write-Host "Tenant $rowIndex/$($rows.Count): $tenantId" -ForegroundColor Cyan
Write-Host "Baseline : $baselinePath" -ForegroundColor Cyan
Write-Host "======================================================" -ForegroundColor Cyan
$params = @{
TenantId = $tenantId
BaselinePath = $baselinePath
}
if ($row.PSObject.Properties['AppId'] -and $row.AppId) { $params.AppId = $row.AppId }
if ($row.PSObject.Properties['Secret'] -and $row.Secret) { $params.Secret = $row.Secret }
if ($row.PSObject.Properties['Certificate'] -and $row.Certificate) { $params.Certificate = $row.Certificate }
if ($row.PSObject.Properties['AuthMode'] -and $row.AuthMode) { $params.AuthMode = $row.AuthMode }
if ($WhatIf -or ($row.PSObject.Properties['WhatIf'] -and $row.WhatIf -match '(?i)^true|yes|1$'))
{
$params.WhatIf = $true
}
if ($ScriptMode -eq "Intune")
{
if ($row.PSObject.Properties['ConflictResolution'] -and $row.ConflictResolution) { $params.ConflictResolution = $row.ConflictResolution }
$params.ReportPath = $tenantReportPath
}
else
{
if ($row.PSObject.Properties['Mode'] -and $row.Mode) { $params.Mode = $row.Mode }
if ($row.PSObject.Properties['Workloads'] -and $row.Workloads)
{
$params.Workloads = $row.Workloads -split '\s*[,;]\s*'
}
}
$outcome = 'Success'
$errorMsg = $null
try
{
& $targetScript @params
}
catch
{
$outcome = 'Failed'
$errorMsg = $_.Exception.Message
Write-Warning "Tenant $tenantId failed: $errorMsg"
}
$batchSummary.Add([PSCustomObject]@{
Row = $rowIndex
TenantId = $tenantId
Baseline = $baselinePath
Outcome = $outcome
ReportPath = if ($ScriptMode -eq "Intune" -and (Test-Path $tenantReportPath)) { $tenantReportPath } else { $null }
Error = $errorMsg
})
}
$summaryPath = Join-Path $resolvedOutputDir "BatchSummary_${ts}.csv"
$batchSummary | Export-Csv -Path $summaryPath -NoTypeInformation -Force
Write-Host ""
Write-Host "======================================================" -ForegroundColor Green
Write-Host "Batch complete. $($rows.Count) tenant(s) processed." -ForegroundColor Green
Write-Host "Summary: $summaryPath" -ForegroundColor Green
$failed = $batchSummary | Where-Object { $_.Outcome -ne 'Success' }
if ($failed)
{
Write-Host "Failed tenants:" -ForegroundColor Red
$failed | ForEach-Object { Write-Host " $($_.TenantId): $($_.Error)" -ForegroundColor Red }
}
Write-Host "======================================================" -ForegroundColor Green
+682
View File
@@ -0,0 +1,682 @@
#requires -Version 7.0
<#
.SYNOPSIS
Generates a Conditional Access baseline YAML manifest from high-level security requirements.
.DESCRIPTION
Creates a CIS M365-compatible baseline YAML file covering Conditional Access policies.
The output can be reviewed and then deployed with Deploy-CISM365Baseline.ps1.
Policy names follow the structured naming convention:
<INDEX>-<TARGET>-<APP/RESOURCE>-<CONTROL>-<SCOPE>
Index ranges:
CA0xx User policies
CA1xx Guest policies
CA2xx Application policies
CA3xx Admin policies
CA4xx Threat policies
Example: CA001-AllUsers-AllApps-BlockLegacyAuth-Prod
.PARAMETER RequireTrustedLocations
Enforce that users can only sign in from trusted named locations.
- None: No location restriction policy
- AllUsers: All users must be on trusted locations
- Admins: Only administrative roles must be on trusted locations
- All: Both AllUsers and Admins policies
.PARAMETER AdminDeviceCompliance
Device requirements for administrative roles.
- None: No device policy for admins
- Required: Admins must use compliant or hybrid-joined devices
- RequiredWithMFA: Admins must use compliant/hybrid-joined devices AND MFA
.PARAMETER GuestMFA
Require MFA for guest and external users.
.PARAMETER SessionTimeoutHours
Require re-authentication after N hours. 0 disables session timeout policies.
.PARAMETER DisablePersistentBrowser
Prevent persistent browser sessions (users must re-auth when browser restarts).
.PARAMETER TrustedLocationsExemptFromReauth
When SessionTimeoutHours is set, do not require re-authentication from trusted locations.
This creates an exclusion so users on trusted networks are not nagged.
.PARAMETER RequireMFAForAllUsers
Require MFA for all member users.
.PARAMETER BlockLegacyAuth
Block all legacy authentication protocols (Exchange ActiveSync, basic auth, etc.).
.PARAMETER BlockHighRiskSignIns
Block sign-ins with medium or high risk level (requires Entra ID P2).
.PARAMETER RequireMFAForAdminPortals
Require MFA when accessing Microsoft admin portals (Azure, M365, Exchange, etc.).
.PARAMETER RequireMFAForAdmins
Require MFA for all administrative roles across all applications.
.PARAMETER RequirePhishingResistantMFAForAdmins
Require phishing-resistant MFA (FIDO2, certificate) for administrative roles.
.PARAMETER BlockDeviceCodeFlow
Block sign-ins using the device code authentication flow.
.PARAMETER RequireManagedDeviceForAllUsers
Require all users to use compliant or hybrid-joined devices.
.PARAMETER OutputPath
Path where the generated YAML baseline will be written.
.PARAMETER Scope
Deployment stage suffix applied to every policy name.
- Test, Pilot1, Pilot2, Pilot3, Prod
.PARAMETER UseDescriptiveNames
Use human-readable descriptive names instead of the structured naming convention.
.PARAMETER Prefix
Optional prefix applied before the INDEX (e.g. "ACME-" produces ACME-CA001-...).
.PARAMETER BreakGlassGroup
Name of the break-glass group to auto-exclude from every CA policy.
.PARAMETER ReportOnly
Default all generated policies to report-only mode (recommended for initial rollout).
.EXAMPLE
# Minimal baseline: MFA for all + block legacy auth
./Scripts/New-ConditionalAccessBaseline.ps1 `
-RequireMFAForAllUsers `
-BlockLegacyAuth `
-OutputPath ./Baselines/MyCA.yaml
.EXAMPLE
# Full security baseline with structured names scoped to production
./Scripts/New-ConditionalAccessBaseline.ps1 `
-RequireTrustedLocations AllUsers `
-AdminDeviceCompliance RequiredWithMFA `
-GuestMFA `
-SessionTimeoutHours 8 `
-DisablePersistentBrowser `
-TrustedLocationsExemptFromReauth `
-BlockLegacyAuth `
-BlockHighRiskSignIns `
-OutputPath ./Baselines/SecureTenant-CA.yaml `
-Scope Prod
.EXAMPLE
# Pilot rollout with descriptive names instead of structured convention
./Scripts/New-ConditionalAccessBaseline.ps1 `
-RequireMFAForAllUsers `
-BlockLegacyAuth `
-OutputPath ./Baselines/Pilot-CA.yaml `
-Scope Pilot1 `
-UseDescriptiveNames
#>
[CmdletBinding()]
param(
[Parameter()]
[ValidateSet('None','AllUsers','Admins','All')]
[string]$RequireTrustedLocations = 'None',
[Parameter()]
[ValidateSet('None','Required','RequiredWithMFA')]
[string]$AdminDeviceCompliance = 'None',
[Parameter()]
[switch]$GuestMFA,
[Parameter()]
[ValidateRange(0,24)]
[int]$SessionTimeoutHours = 0,
[Parameter()]
[switch]$DisablePersistentBrowser,
[Parameter()]
[switch]$TrustedLocationsExemptFromReauth,
[Parameter()]
[switch]$RequireMFAForAllUsers,
[Parameter()]
[switch]$BlockLegacyAuth,
[Parameter()]
[switch]$BlockHighRiskSignIns,
[Parameter()]
[switch]$RequireMFAForAdminPortals,
[Parameter()]
[switch]$RequireMFAForAdmins,
[Parameter()]
[switch]$RequirePhishingResistantMFAForAdmins,
[Parameter()]
[switch]$BlockDeviceCodeFlow,
[Parameter()]
[switch]$RequireManagedDeviceForAllUsers,
[Parameter(Mandatory = $true)]
[string]$OutputPath,
[Parameter()]
[ValidateSet('Test','Pilot1','Pilot2','Pilot3','Prod')]
[string]$Scope = 'Prod',
[Parameter()]
[switch]$UseDescriptiveNames,
[Parameter()]
[string]$Prefix = '',
[Parameter()]
[string]$BreakGlassGroup = 'CIS-BreakGlass',
[Parameter()]
[switch]$ReportOnly
)
$ErrorActionPreference = 'Stop'
# =====================================================================
# Naming convention engine
# =====================================================================
# Format: CA<area><scope><seq2digit>-<TARGET>-<APP/RESOURCE>-<CONTROL>
# Area: 0=Threat/Tenant, 1=User, 2=Admin, 3=Guest, 4=Application
# Scope: 0=Test, 1=Pilot1, 2=Pilot2, 3=Pilot3, 9=Prod
# Seq: auto-increment per area
# =====================================================================
$script:AreaDigitMap = @{
'User' = '1'
'Guest' = '3'
'Application' = '4'
'Admin' = '2'
'Threat' = '0'
}
$script:ScopeDigitMap = @{
'Test' = '0'
'Pilot1' = '1'
'Pilot2' = '2'
'Pilot3' = '3'
'Prod' = '9'
}
$script:NextSeq = @{
'0' = 1
'1' = 1
'2' = 1
'3' = 1
'4' = 1
}
function Get-StructuredPolicyName {
param(
[Parameter(Mandatory)]
[ValidateSet('User','Guest','Application','Admin','Threat')]
[string]$Category,
[Parameter(Mandatory)]
[string]$Target,
[Parameter(Mandatory)]
[string]$AppResource,
[Parameter(Mandatory)]
[string]$Control
)
$area = $script:AreaDigitMap[$Category]
$scope = $script:ScopeDigitMap[$Scope]
$seq = $script:NextSeq[$area]++
$idx = "$area$scope$($seq.ToString('D2'))"
$name = "CA$idx-${Target}-${AppResource}-${Control}"
if ($Prefix) { $name = "$Prefix$name" }
return $name
}
function Get-DescriptivePolicyName {
param([string]$Name)
if ($Prefix) { return "$Prefix$Name" }
return $Name
}
function Get-DefaultState {
if ($ReportOnly) { return 'enabledForReportingButNotEnforced' }
return 'enabled'
}
# =====================================================================
# Shared data
# =====================================================================
$script:AdminRoles = @(
'Global Administrator',
'Privileged Role Administrator',
'Security Administrator',
'Exchange Administrator',
'SharePoint Administrator',
'Conditional Access Administrator',
'Application Administrator',
'Cloud Application Administrator',
'User Administrator',
'Helpdesk Administrator',
'Billing Administrator',
'Authentication Administrator',
'Password Administrator'
)
$script:AdminPortalAppIds = @(
'797f4846-ba00-4fd7-ba43-dac1f8f63013', # Azure Management
'c44b4083-3bb0-49c1-b47d-974e53cbdf3c', # Azure AD PowerShell
'1b730954-1685-4b74-9bfd-dac224a7b894', # Microsoft Graph PowerShell
'00000003-0000-0ff1-ce00-000000000000', # Office 365 Exchange Online
'00000003-0000-0000-c000-000000000000', # Microsoft Graph
'de8bc8b5-d9f9-48b1-a8ad-b748da725064', # Microsoft Intune
'00000002-0000-0ff1-ce00-000000000000', # Office 365 SharePoint Online
'66a88757-258c-4c72-893c-3e8bed4d6899' # Microsoft365DSC
)
# =====================================================================
# Policy builders
# =====================================================================
function New-PolicyBlockLegacyAuth {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-Legacy-Authentication' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockLegacyAuth }
description = 'Block all legacy authentication protocols (EAS, basic auth, IMAP, POP, etc.)'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
clientAppTypes = @('exchangeActiveSync', 'other')
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireMFAAllUsers {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control RequireMFA }
description = 'Require multi-factor authentication for all users'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireMFAAdmins {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireMFA }
description = 'Require multi-factor authentication for all administrative roles'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeRoles = $script:AdminRoles }
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireMFAAdminPortals {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Admin-Portals' } else { Get-StructuredPolicyName -Category Application -Target AllUsers -AppResource AdminPortals -Control RequireMFA }
description = 'Require MFA when accessing Microsoft admin portals'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = $script:AdminPortalAppIds }
users = @{ includeUsers = @('All') }
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicyTrustedLocations {
param([switch]$ForAdmins)
if ($ForAdmins) {
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Trusted-Locations-Only-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control BlockUntrustedLocations }
$desc = 'Administrators can only sign in from trusted named locations'
$userDef = @{ includeRoles = $script:AdminRoles }
} else {
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Trusted-Locations-Only-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control BlockUntrustedLocations }
$desc = 'All users can only sign in from trusted named locations'
$userDef = @{ includeUsers = @('All') }
}
$policy = @{
name = $name
description = $desc
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = $userDef
locations = @{
includeLocations = @('All')
excludeLocations = @('AllTrusted')
}
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyAdminDeviceCompliance {
param([switch]$WithMFA)
$controls = @('compliantDevice', 'domainJoinedDevice')
$operator = 'OR'
$desc = 'Administrators must use compliant or hybrid-joined devices'
if ($WithMFA) {
$controls = @('compliantDevice', 'domainJoinedDevice', 'mfa')
$operator = 'AND'
$desc = 'Administrators must use compliant/hybrid-joined devices AND MFA'
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Compliant-Device-and-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireCompliantDeviceAndMFA }
} else {
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Compliant-Device-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireCompliantDevice }
}
$policy = @{
name = $name
description = $desc
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeRoles = $script:AdminRoles }
}
grantControls = @{
builtInControls = $controls
operator = $operator
}
}
return $policy
}
function New-PolicyGuestMFA {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Guests' } else { Get-StructuredPolicyName -Category Guest -Target Guests -AppResource AllApps -Control RequireMFA }
description = 'Require multi-factor authentication for guest and external users'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{
includeGuestsOrExternalUsers = @{
guestTypes = @('internalGuest', 'b2bCollaborationGuest', 'b2bCollaborationMember', 'b2bDirectConnectUser')
externalTenants = @{ membershipKind = 'all' }
}
}
}
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
return $policy
}
function New-PolicySessionControls {
param(
[int]$TimeoutHours = 0,
[switch]$DisablePersistent,
[switch]$ExemptTrustedLocations
)
$sessionControls = @{}
$parts = [System.Collections.Generic.List[string]]::new()
if ($TimeoutHours -gt 0) {
$sessionControls['signInFrequency'] = @{
value = $TimeoutHours
type = 'hours'
isEnabled = $true
}
$parts.Add("re-authenticate every $TimeoutHours hours")
}
if ($DisablePersistent) {
$sessionControls['persistentBrowser'] = @{
mode = 'never'
isEnabled = $true
}
$parts.Add('no persistent browser sessions')
}
$desc = 'Session controls: ' + ($parts -join '; ')
if ($ExemptTrustedLocations) {
$desc += ' (exempt when on trusted locations)'
}
$controlTag = if ($TimeoutHours -gt 0 -and $DisablePersistent) {
'SessionControls'
} elseif ($TimeoutHours -gt 0) {
'SignInFrequency'
} else {
'NoPersistentBrowser'
}
$name = if ($UseDescriptiveNames) {
if ($TimeoutHours -gt 0 -and $DisablePersistent) {
Get-DescriptivePolicyName 'Session-Timeout-and-No-Persistent-Browser'
} elseif ($TimeoutHours -gt 0) {
Get-DescriptivePolicyName "Session-Timeout-${TimeoutHours}h"
} else {
Get-DescriptivePolicyName 'No-Persistent-Browser'
}
} else {
Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control $controlTag
}
$conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
}
if ($ExemptTrustedLocations) {
$conditions['locations'] = @{
excludeLocations = @('AllTrusted')
}
}
$policy = @{
name = $name
description = $desc
state = Get-DefaultState
conditions = $conditions
grantControls = @{
builtInControls = @('mfa')
operator = 'OR'
}
}
if ($sessionControls.Count -gt 0) {
$policy['sessionControls'] = $sessionControls
}
return $policy
}
function New-PolicyBlockHighRisk {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-High-Risk-SignIns' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockHighRisk }
description = 'Block sign-ins with medium or high risk score (requires Entra ID P2)'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
signInRiskLevels = @('medium', 'high')
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyPhishingResistantMFAAdmins {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-PhishingResistant-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequirePhishingResistantMFA }
description = 'Require phishing-resistant MFA (FIDO2, certificate) for administrative roles'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeRoles = $script:AdminRoles }
}
grantControls = @{
builtInControls = @('authenticationStrength')
authenticationStrength = @{ id = '00000000-0000-0000-0000-000000000004' }
operator = 'OR'
}
}
return $policy
}
function New-PolicyBlockDeviceCodeFlow {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-Device-Code-Flow' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockDeviceCodeFlow }
description = 'Block sign-ins using the device code authentication flow'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
authenticationFlows = @{
deviceCodeFlow = @{ isEnabled = $true }
}
}
grantControls = @{
builtInControls = @('block')
operator = 'OR'
}
}
return $policy
}
function New-PolicyRequireManagedDeviceAllUsers {
$policy = @{
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Managed-Device-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control RequireCompliantDevice }
description = 'Require all users to use compliant or hybrid-joined devices'
state = Get-DefaultState
conditions = @{
applications = @{ includeApplications = @('All') }
users = @{ includeUsers = @('All') }
}
grantControls = @{
builtInControls = @('compliantDevice', 'domainJoinedDevice')
operator = 'OR'
}
}
return $policy
}
# =====================================================================
# Build the policy list based on parameters
# =====================================================================
$policies = [System.Collections.Generic.List[hashtable]]::new()
if ($BlockLegacyAuth) { $policies.Add((New-PolicyBlockLegacyAuth)) }
if ($RequireMFAForAllUsers) { $policies.Add((New-PolicyRequireMFAAllUsers)) }
if ($RequireMFAForAdmins) { $policies.Add((New-PolicyRequireMFAAdmins)) }
if ($RequireMFAForAdminPortals) { $policies.Add((New-PolicyRequireMFAAdminPortals)) }
if ($BlockHighRiskSignIns) { $policies.Add((New-PolicyBlockHighRisk)) }
if ($BlockDeviceCodeFlow) { $policies.Add((New-PolicyBlockDeviceCodeFlow)) }
if ($RequirePhishingResistantMFAForAdmins) { $policies.Add((New-PolicyPhishingResistantMFAAdmins)) }
if ($RequireManagedDeviceForAllUsers) { $policies.Add((New-PolicyRequireManagedDeviceAllUsers)) }
switch ($RequireTrustedLocations) {
'AllUsers' { $policies.Add((New-PolicyTrustedLocations)) }
'Admins' { $policies.Add((New-PolicyTrustedLocations -ForAdmins)) }
'All' { $policies.Add((New-PolicyTrustedLocations)); $policies.Add((New-PolicyTrustedLocations -ForAdmins)) }
}
switch ($AdminDeviceCompliance) {
'Required' { $policies.Add((New-PolicyAdminDeviceCompliance)) }
'RequiredWithMFA' { $policies.Add((New-PolicyAdminDeviceCompliance -WithMFA)) }
}
if ($GuestMFA) { $policies.Add((New-PolicyGuestMFA)) }
if ($SessionTimeoutHours -gt 0 -or $DisablePersistentBrowser) {
$policies.Add((New-PolicySessionControls `
-TimeoutHours $SessionTimeoutHours `
-DisablePersistent:$DisablePersistentBrowser `
-ExemptTrustedLocations:$TrustedLocationsExemptFromReauth))
}
if ($policies.Count -eq 0) {
throw "No policies requested. Specify at least one requirement parameter (e.g. -RequireMFAForAllUsers, -BlockLegacyAuth, etc.)."
}
# =====================================================================
# Serialize to YAML (requires powershell-yaml)
# =====================================================================
function Test-YamlModule {
return [bool](Get-Module -ListAvailable -Name powershell-yaml)
}
if (-not (Test-YamlModule)) {
Write-Host "powershell-yaml module is required but not installed." -ForegroundColor Yellow
$confirm = Read-Host "Install powershell-yaml from PSGallery now? [Y/n]"
if ($confirm -match "^\s*n") {
throw "powershell-yaml is required. Install it with: Install-Module powershell-yaml -Scope CurrentUser -Force"
}
Install-Module powershell-yaml -Scope CurrentUser -Force
}
Import-Module powershell-yaml -Force
# Build the root document
$yamlRoot = [ordered]@{
baseline = [ordered]@{
name = 'Generated-ConditionalAccess-Baseline'
conflictResolution = 'Skip'
whatIf = $false
tenantConfig = [ordered]@{
conditionalAccess = [ordered]@{
reportOnly = $true
breakGlassGroup = $BreakGlassGroup
policies = $policies
}
}
}
}
$yamlText = ConvertTo-Yaml -Data $yamlRoot
# Ensure output directory exists
$outDir = Split-Path -Parent $OutputPath
if ($outDir -and -not (Test-Path $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
$yamlText | Set-Content -Path $OutputPath -Encoding UTF8 -Force
Write-Host "Generated Conditional Access baseline with $($policies.Count) policies." -ForegroundColor Green
Write-Host "Output written to: $(Resolve-Path $OutputPath)" -ForegroundColor Green
Write-Host ""
Write-Host "Review the file, then deploy with:" -ForegroundColor Cyan
Write-Host " ./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath '$OutputPath' -Mode Assess" -ForegroundColor Yellow
Write-Host " ./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath '$OutputPath' -Mode Deploy -Apply" -ForegroundColor Yellow
@@ -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-')"
+99
View File
@@ -0,0 +1,99 @@
#requires -Version 7.0
<#
.SYNOPSIS
Launches the interactive Conditional Access Policy Wizard (TUI).
.DESCRIPTION
Starts the Python-based TUI wizard that guides you through tenant,
user, admin, guest, and application policy choices. The wizard
generates a deployment-ready YAML baseline using the structured
naming convention.
Automatically locates the project venv or system Python with the
required packages (rich, pyyaml).
.EXAMPLE
./Scripts/Start-CAWizard.ps1
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$wizardPath = Join-Path $PSScriptRoot 'ca-wizard.py'
if (-not (Test-Path $wizardPath)) {
throw "Wizard script not found: $wizardPath"
}
# =====================================================================
# Resolve Python interpreter
# =====================================================================
function Test-PythonPackages {
param([string]$PyExe)
if (-not $PyExe) { return $false }
try {
$result = & $PyExe -c "import rich, yaml" 2>&1
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
$candidates = [System.Collections.Generic.List[string]]::new()
# 1. Project venv (Linux/macOS)
$venvPy = Join-Path (Split-Path $PSScriptRoot -Parent) '.venv-pdf/bin/python3'
if (Test-Path $venvPy) { $candidates.Add($venvPy) }
# 2. Project venv (Windows)
$venvPyWin = Join-Path (Split-Path $PSScriptRoot -Parent) '.venv-pdf/Scripts/python.exe'
if (Test-Path $venvPyWin) { $candidates.Add($venvPyWin) }
# 3. Common system commands
foreach ($cmd in @('python3', 'python')) {
$found = Get-Command $cmd -ErrorAction SilentlyContinue
if ($found) { $candidates.Add($found.Source) }
}
$pythonPath = $null
foreach ($c in $candidates) {
if (Test-PythonPackages -PyExe $c) {
$pythonPath = $c
break
}
}
# If nothing has the packages, try installing into the venv
if (-not $pythonPath) {
$venvPy = $candidates | Where-Object { $_ -match '\.venv' } | Select-Object -First 1
if ($venvPy -and (Test-Path $venvPy)) {
Write-Host "Installing required packages into venv..." -ForegroundColor Yellow
$pip = Join-Path (Split-Path $venvPy -Parent) 'pip'
if (-not (Test-Path $pip)) { $pip = Join-Path (Split-Path $venvPy -Parent) 'pip3' }
& $pip install rich pyyaml 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
if (Test-PythonPackages -PyExe $venvPy) {
$pythonPath = $venvPy
}
}
}
if (-not $pythonPath) {
throw @"
Could not find a Python interpreter with 'rich' and 'pyyaml' installed.
Please install the requirements:
python3 -m pip install rich pyyaml
Or activate the project venv manually:
source .venv-pdf/bin/activate
python3 Scripts/ca-wizard.py
"@
}
Write-Host "Using Python: $pythonPath" -ForegroundColor DarkGray
# =====================================================================
# Run wizard
# =====================================================================
& $pythonPath $wizardPath
+263
View File
@@ -0,0 +1,263 @@
[CmdletBinding()]
param(
[ValidateSet("Export","Import","DeployCISBaseline","GenerateReports")]
[string]$Action,
[string]$BaselinePath,
[ValidateSet("Assess","Deploy")]
[string]$Mode = "Assess",
[string[]]$Workloads,
[switch]$Apply,
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile,
[string]$BatchFile,
[string]$NameFilter = "",
[string]$NameSearchPattern = "",
[string]$NameReplacePattern = "",
[string[]]$ObjectTypes,
[string]$ExportPath,
[string]$ImportPath,
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
[string]$ImportType = "alwaysImport",
[switch]$IncludeAssignments,
[switch]$AddCompanyName,
[switch]$IncludeScopeTags,
[switch]$ReplaceDependencyIds,
[switch]$Interactive,
# GenerateReports params
[ValidateSet("Settings","Assignments","ObjectInventory","All")]
[string]$ReportType = "All",
[string]$BackupRoot,
[string]$OutputDir,
[string]$DataSource,
[switch]$IncludeAssignmentsInSettings
)
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
Import-Module $modulePath -Force
if($Interactive -and -not $Action)
{
Write-Host "Interactive mode will prompt for the action and other settings." -ForegroundColor Cyan
}
elseif(-not $Action)
{
throw "Action is required. Use -Interactive to select it in a terminal UI."
}
if($Interactive)
{
$tuiScript = Join-Path (Split-Path -Parent $PSScriptRoot) "Scripts/Private/Start-IntuneManagementTui.ps1"
if(Test-Path $tuiScript)
{
$tuiResult = & $tuiScript
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
-458
View File
@@ -1,458 +0,0 @@
#requires -Version 5.1
<#
.SYNOPSIS
Unified launcher for the macOS Intune Toolkit.
.DESCRIPTION
Presents a single terminal UI to choose from all available
headless Intune management tools. Passes through common auth parameters.
Press Esc to go back to the menu from any selection.
.EXAMPLE
./Scripts/Start-IntuneToolkit.ps1 -TenantId "contoso.onmicrosoft.com"
#>
[CmdletBinding()]
param(
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfHint
{
if(Test-FzfAvailable) { return }
Write-Host "`n[fzf not found]" -ForegroundColor Yellow -NoNewline
Write-Host " Install fzf for the best interactive menu experience.`n" -ForegroundColor DarkGray
if($IsMacOS)
{
Write-Host " macOS: brew install fzf" -ForegroundColor DarkGray
}
elseif($IsLinux)
{
Write-Host " Debian/Ubuntu: sudo apt install fzf" -ForegroundColor DarkGray
Write-Host " Fedora: sudo dnf install fzf" -ForegroundColor DarkGray
Write-Host " Arch: sudo pacman -S fzf" -ForegroundColor DarkGray
}
else
{
Write-Host " Windows: winget install junegunn.fzf" -ForegroundColor DarkGray
Write-Host " choco install fzf" -ForegroundColor DarkGray
}
Write-Host " (Falling back to numbered menus for now.)`n" -ForegroundColor DarkGray
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
$selected = $Items | fzf --header=$Header
if(-not $selected) { return $null }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
$choice = Read-Host "Enter a number (0 to exit)"
if($choice -eq "0") { return "EXIT" }
$index = [int]$choice - 1
if($index -ge 0 -and $index -lt $Items.Count)
{
return $Items[$index]
}
return $null
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header
}
return Show-NumberedMenu -Items $Items -Header $Header
}
#endregion
$projectRoot = Split-Path -Parent $PSScriptRoot
#region Tenant selection
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
function Get-SavedTenants
{
param([string]$SettingsPath)
if(-not (Test-Path $SettingsPath)) { return @() }
try
{
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
$tenants = @()
foreach($key in $raw.Keys)
{
if($key -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')
{
$name = $null
if($raw[$key] -is [hashtable] -and $raw[$key].ContainsKey('TenantName'))
{
$name = $raw[$key]['TenantName']
}
elseif($raw[$key] -is [psobject] -and $raw[$key].PSObject.Properties['TenantName'])
{
$name = $raw[$key].TenantName
}
$display = if($name) { "$name ($key)" } else { $key }
$tenants += [PSCustomObject]@{ TenantId = $key; TenantName = $name; Display = $display }
}
}
return $tenants | Sort-Object Display
}
catch
{
return @()
}
}
function Update-TenantNameCache
{
param([string]$SettingsPath, [string]$TenantId, [string]$TenantName)
if(-not (Test-Path $SettingsPath)) { return }
try
{
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
if($raw[$TenantId] -is [hashtable])
{
$raw[$TenantId]['TenantName'] = $TenantName
}
else
{
$raw[$TenantId] = @{ TenantName = $TenantName }
}
$raw | ConvertTo-Json -Depth 10 | Set-Content -Path $SettingsPath -Force
}
catch { }
}
function Resolve-TenantName
{
param([string]$TenantId, [string]$SettingsPath)
$settingsObj = $null
try
{
$settingsObj = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
}
catch { return $null }
$tenantNode = $settingsObj[$TenantId]
if(-not $tenantNode) { return $null }
$appId = $tenantNode['GraphAzureAppId']
if(-not $appId) { return $null }
$secret = $tenantNode['GraphAzureAppSecret']
$cert = $tenantNode['GraphAzureAppCert']
if(-not $secret -and $IsMacOS)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $secret = $keychainSecret }
}
catch { }
}
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule)) { return $null }
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $SettingsPath
TenantId = $TenantId
AppId = $appId
AuthMode = "AppOnly"
}
if($secret) { $invokeParams.Secret = $secret }
elseif($cert) { $invokeParams.Certificate = $cert }
try
{
Import-Module $runtimeModule -Force | Out-Null
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams | Out-Null
if(Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue)
{
$org = Invoke-GraphRequest "/organization" -ErrorAction Stop
if($org.value -and $org.value[0].displayName)
{
return $org.value[0].displayName
}
}
}
catch { }
return $null
}
$settingsPath = $SettingsFile
if(-not $settingsPath) { $settingsPath = Get-DefaultSettingsPath }
if(-not $TenantId)
{
$tenants = Get-SavedTenants -SettingsPath $settingsPath
$tenantOptions = @()
foreach($t in $tenants)
{
$tenantOptions += $t.Display
}
$tenantOptions += "[+ Onboard new tenant]"
$tenantOptions += "[Exit]"
$selectedTenantDisplay = Select-MenuItem -Items $tenantOptions -Header "Select a tenant"
if(-not $selectedTenantDisplay -or $selectedTenantDisplay -eq "[Exit]")
{
exit 0
}
elseif($selectedTenantDisplay -eq "[+ Onboard new tenant]")
{
$TenantId = Read-Host "Enter the new Tenant ID (GUID)"
if(-not $TenantId)
{
Write-Host "No tenant ID provided. Exiting." -ForegroundColor Yellow
exit 0
}
$initPath = Join-Path $projectRoot "Scripts/Initialize-IntuneAuth.ps1"
& $initPath -TenantId $TenantId
Write-Host "`nOnboarding complete. Restarting launcher..." -ForegroundColor Green
Start-Sleep -Seconds 1
& $PSCommandPath
exit 0
}
else
{
$TenantId = $selectedTenantDisplay -replace '.*\(([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\)$', '$1'
if(-not $TenantId)
{
$TenantId = $selectedTenantDisplay
}
}
}
$currentTenant = (Get-SavedTenants -SettingsPath $settingsPath) | Where-Object { $_.TenantId -eq $TenantId } | Select-Object -First 1
if(-not $currentTenant -or -not $currentTenant.TenantName)
{
Write-Host "`nResolving tenant name..." -ForegroundColor Cyan
$resolvedName = Resolve-TenantName -TenantId $TenantId -SettingsPath $settingsPath
if($resolvedName)
{
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $TenantId -TenantName $resolvedName
Write-Host "Cached tenant name: $resolvedName" -ForegroundColor Green
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $resolvedName; Display = "$resolvedName ($TenantId)" }
}
}
#endregion
Show-FzfHint
# Build common parameter hashtable
$commonParams = @{
TenantId = $TenantId
AppId = $AppId
Secret = $Secret
Certificate = $Certificate
AuthMode = $AuthMode
RedirectUri = $RedirectUri
SettingsFile = $SettingsFile
}
$menuItems = @(
"15. Delete tenant auth and app registration"
"14. Delete local tenant auth only"
"13. Refresh tenant names"
"12. Initialize auth (one-time setup)"
"11. Deploy baseline (dry-run / WhatIf)"
"10. Deploy baseline"
"9. Bulk device operations"
"8. Bulk rename policies"
"7. Export assignments to CSV/Markdown"
"6. Restore assignments"
"5. Backup assignments"
"4. Bulk assignment manager (policies)"
"3. Bulk app assignment"
"2. Import policies"
"1. Export policies"
"0. Exit"
)
while($true)
{
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " macOS Intune Toolkit" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if($currentTenant -and $currentTenant.TenantName)
{
Write-Host " Tenant: $($currentTenant.TenantName) ($TenantId)" -ForegroundColor Green
}
else
{
Write-Host " Tenant: $TenantId" -ForegroundColor Green
}
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
$selection = Select-MenuItem -Items $menuItems -Header "Select a tool to launch"
if(-not $selection)
{
continue
}
if($selection -eq "EXIT" -or $selection -like "*0. Exit*")
{
Write-Host "`nExiting. Goodbye!" -ForegroundColor Yellow
exit 0
}
$choiceNumber = [int]($selection -replace "^(\d+)\..*$", '$1')
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" }
10 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
11 { $script = "Scripts/Deploy-IntuneBaseline.ps1"; $commonParams.WhatIf = $true }
12 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
13 { $script = $null }
14 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
15 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
default { continue }
}
# Clear any mode-specific params from previous loop iteration
$commonParams.Remove("Interactive")
$commonParams.Remove("Mode")
$commonParams.Remove("WhatIf")
switch($choiceNumber)
{
1 { $commonParams.Interactive = $true }
2 { $commonParams.Interactive = $true }
5 { $commonParams.Mode = "Backup" }
6 { $commonParams.Mode = "Restore" }
11 { $commonParams.WhatIf = $true }
}
if($choiceNumber -eq 13)
{
Write-Host "`nRefreshing tenant names..." -ForegroundColor Cyan
$tenantsToRefresh = Get-SavedTenants -SettingsPath $settingsPath
$refreshed = 0
$failed = 0
foreach($t in $tenantsToRefresh)
{
Write-Host " Resolving $($t.TenantId) ..." -ForegroundColor DarkGray -NoNewline
$name = Resolve-TenantName -TenantId $t.TenantId -SettingsPath $settingsPath
if($name)
{
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $t.TenantId -TenantName $name
Write-Host " -> $name" -ForegroundColor Green
$refreshed++
if($t.TenantId -eq $TenantId)
{
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $name; Display = "$name ($TenantId)" }
}
}
else
{
Write-Host " -> FAILED" -ForegroundColor Red
$failed++
}
}
Write-Host "`nRefresh complete. Success: $refreshed, Failed: $failed" -ForegroundColor Cyan
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
$scriptPath = Join-Path $projectRoot $script
if(-not (Test-Path $scriptPath))
{
throw "Script not found: $scriptPath"
}
Write-Host "`nLaunching $script ...`n" -ForegroundColor Green
# Clone params and sanitize for scripts that don't accept the full auth set
$launchParams = $commonParams.Clone()
if($script -eq "Scripts/Initialize-IntuneAuth.ps1")
{
@("AppId","Secret","Certificate","AuthMode","RedirectUri","Interactive","Mode","WhatIf") | ForEach-Object { $launchParams.Remove($_) }
}
if($choiceNumber -eq 14)
{
$launchParams.Delete = $true
}
if($choiceNumber -eq 15)
{
$launchParams.DeleteApp = $true
}
# Execute in same process so TUI flows naturally
& $scriptPath @launchParams
if($choiceNumber -eq 14 -or $choiceNumber -eq 15)
{
Write-Host "`nTenant auth deleted. Exiting." -ForegroundColor Yellow
exit 0
}
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
+778
View File
@@ -0,0 +1,778 @@
#!/usr/bin/env python3
"""
Convert CIS M365 v7.0.0 draft PDF to YAML baseline manifest.
Called by ConvertFrom-CISPDF.ps1
"""
import sys
import re
from pathlib import Path
from pypdf import PdfReader
def parse_profiles(pa_text: str | None) -> set[tuple[str, str]]:
"""Extract (level, license) tuples from Profile Applicability text.
Example: '• E3 Level 1 • E5 Level 2'{('L1','E3'), ('L2','E5')}
"""
if not pa_text:
return set()
profiles = set()
# Split by bullet to avoid cross-bullet matching
bullets = re.split(r'\s*•\s*', pa_text)
for bullet in bullets:
bullet = bullet.strip()
if not bullet:
continue
# Look for patterns like "E3 Level 1" or "Level 1 E3" within a single bullet
m = re.search(r'\b(E3|E5)\b.*\bLevel\s+(1|2)\b', bullet, re.IGNORECASE)
if not m:
m = re.search(r'\bLevel\s+(1|2)\b.*\b(E3|E5)\b', bullet, re.IGNORECASE)
if m:
level = f"L{m.group(1)}"
license = m.group(2).upper()
profiles.add((level, license))
else:
level = f"L{m.group(2)}"
license = m.group(1).upper()
profiles.add((level, license))
return profiles
def format_profiles(profiles: set[tuple[str, str]]) -> str:
"""Format profile set as compact badge string."""
if not profiles:
return ""
return "[" + ", ".join(f"{lvl}·{lic}" for lvl, lic in sorted(profiles)) + "]"
def matches_filter(profiles: set[tuple[str, str]], level_filter: str, license_filter: str) -> bool:
"""Check if a control's profiles match the requested level/license filters.
A control matches if at least one of its (level, license) tuples matches both filters.
"""
if not profiles:
return True # If we can't parse profiles, include by default
for lvl, lic in profiles:
level_ok = level_filter == 'Both' or level_filter == lvl
license_ok = license_filter == 'Both' or license_filter == lic
if level_ok and license_ok:
return True
return False
def parse_pdf(pdf_path: str) -> list[dict]:
"""Extract and parse all recommendations from the PDF."""
reader = PdfReader(pdf_path)
full_text = ""
for page in reader.pages:
full_text += "\n" + (page.extract_text() or "")
m = re.search(r'Profile Applicability:\s*\n\s*•\s*E3', full_text)
content_start = m.start() if m else 0
content = full_text[content_start:]
content = re.sub(r'\nPage \d+\s*\n', '\n', content)
section_headers = {
'Overview', 'Groups', 'Devices', 'Enterprise apps', 'External Identities',
'User experiences', 'Authentication Methods', 'Password reset', 'Identity Protection',
'Conditional Access', 'Protection', 'Hybrid management', 'Audit', 'Mail flow',
'Roles', 'Mobile Device Management', 'Application Permissions', 'Settings',
'Teams & groups', 'Users', 'External sharing', 'Guest access', 'Device access',
'User risk', 'Sign-in risk', 'Access reviews', 'Privileged Identity Management',
'Administration center', 'Email and collaboration', 'Tenant settings',
'Meetings', 'Messaging', 'Teams and channels', 'App permissions',
'External access', 'Data sharing', 'File sharing', 'Site settings',
'Service principals', 'Workspaces', 'External domains', 'External emails',
'Meeting policies', 'Calling policies', 'Teams policies', 'Channel policies',
'App setup policies', 'Permission policies', 'Update policies',
'Compliance policies', 'Retention policies', 'Sensitivity labels',
'Data loss prevention', 'Information barriers', 'Communication compliance',
'Insider risk management', 'Records management', 'eDiscovery',
'Customer Lockbox', 'Audit log', 'Reports', 'Alerts',
'Anti-spam', 'Anti-malware', 'Anti-phishing', 'Safe Attachments',
'Safe Links', 'Outbound spam', 'Connection filter', 'Mail flow rules',
'Transport rules', 'Journal rules', 'Data connectors',
'Sensitivity label policies', 'Auto-labeling policies',
'Information protection', 'Data governance', 'Compliance Manager',
'Service assurance', 'Health', 'Message center', 'Adoption Score',
'Usage reports', 'Productivity Score', 'Org settings',
'Security & Privacy', 'Organization profile', 'Partner relationships',
'Billing', 'Purchase services', 'Subscriptions', 'Licenses',
'Payment methods', 'Billing notifications', 'Invoice',
'Active users', 'Deleted users', 'Guest users',
'Contacts', 'Sign-in options',
'Custom domain names', 'DNS records', 'Domain settings',
'Shared mailboxes', 'Resource mailboxes', 'Distribution groups',
'Dynamic distribution groups', 'Mail-enabled security groups',
'Office 365 groups', 'Security groups', 'Mail contacts',
'Migration', 'Data migration', 'IMAP migration',
'Cutover migration', 'Staged migration', 'Minimal hybrid',
'Express migration', 'Cross-tenant migration',
'Setup', 'Connectors',
'Azure AD', 'Support',
'Training', 'Policies', 'Resources', 'Mail',
'Sites', 'Apps', 'Power Platform',
'Dynamics 365', 'Azure', 'Microsoft 365',
'Intune', 'Entra', 'Exchange', 'SharePoint',
'OneDrive', 'Power BI', 'Power Apps',
'Power Automate', 'Power Virtual Agents', 'Copilot',
}
pa_positions = [m.start() for m in re.finditer(r'Profile Applicability:', content)]
recommendations = []
for i, pa_pos in enumerate(pa_positions):
window_start = max(0, pa_pos - 800)
window = content[window_start:pa_pos]
title_match = None
for m in re.finditer(r'(\d+\.\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
title_match = m
if not title_match:
for m in re.finditer(r'(\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
title_match = m
if not title_match:
continue
control_num = title_match.group(1)
title = title_match.group(2).replace('\n', ' ').strip()
title = re.sub(r'\s+', ' ', title)
status = title_match.group(3)
if title in section_headers:
continue
rec_start = title_match.start() + window_start
rec_end = pa_positions[i + 1] if i + 1 < len(pa_positions) else len(content)
chunk = content[rec_start:rec_end]
def extract_field(field_name: str, chunk_text: str) -> str | None:
pattern = re.compile(
re.escape(field_name) + r':\s*\n?\s*(.*?)(?=\n\s*[A-Z][a-zA-Z\s]+:\s*\n|\Z)',
re.DOTALL
)
m = pattern.search(chunk_text)
if m:
val = m.group(1).strip()
val = re.sub(r'\s+', ' ', val)
return val
return None
rec = {
'control': control_num,
'title': title,
'status': status,
'profile_applicability': extract_field('Profile Applicability', chunk),
'description': extract_field('Description', chunk),
'rationale': extract_field('Rationale', chunk),
'impact': extract_field('Impact', chunk),
'default_value': extract_field('Default Value', chunk),
}
rem_match = re.search(r'Remediation:\s*(.*?)(?=Audit:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
if rem_match:
rec['remediation'] = re.sub(r'\s+', ' ', rem_match.group(1))[:1000]
audit_match = re.search(r'Audit:\s*(.*?)(?=Remediation:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
if audit_match:
rec['audit'] = re.sub(r'\s+', ' ', audit_match.group(1))[:1000]
recommendations.append(rec)
seen = set()
unique = []
for r in recommendations:
if r['control'] not in seen:
seen.add(r['control'])
unique.append(r)
return unique
def generate_yaml(recommendations: list[dict], prefix: str, level_filter: str = 'Both', license_filter: str = 'Both') -> str:
"""Generate YAML baseline from parsed recommendations."""
lines = []
lines.append("# =====================================================================")
lines.append("# CIS Microsoft 365 Foundations Benchmark v7.0.0 (Draft)")
lines.append("# GENERATED from PDF — review before deploying")
lines.append("# =====================================================================")
lines.append("")
lines.append("baseline:")
lines.append(f' name: CIS-M365-v7-Generated')
lines.append(' conflictResolution: Skip')
lines.append(' whatIf: false')
lines.append("")
lines.append(' tenantMutation:')
lines.append(f' prefix: "{prefix}"')
lines.append("")
lines.append(' groups:')
lines.append(' - displayName: "CIS-BreakGlass"')
lines.append(' mailNickname: "CISBreakGlass"')
lines.append(' securityEnabled: true')
lines.append(' - displayName: "CIS-Pilot-Users"')
lines.append(' mailNickname: "CISPilotUsers"')
lines.append(' securityEnabled: true')
lines.append("")
lines.append(' tenantConfig:')
section_names = {
'1': 'adminCenter',
'2': 'defender',
'3': 'purview',
'5': 'entraId',
'6': 'exchange',
'7': 'sharePoint',
'8': 'teams',
'9': 'powerBI',
}
# =====================================================================
# COMPREHENSIVE CONTROL MAPPINGS
# =====================================================================
# Simple scalar/boolean mappings: control -> (yaml_section, yaml_key, value)
simple_mappings = {
# --- Section 1: Admin Center ---
'1.3.1': ('adminCenter', 'passwordExpiration', 'NeverExpire'),
'1.3.2': ('adminCenter', 'idleSessionTimeoutHours', 3),
'1.3.4': ('adminCenter', 'restrictUserOwnedApps', True),
'1.3.5': ('adminCenter', 'formsPhishingProtection', True),
'1.3.6': ('adminCenter', 'customerLockbox', True),
'1.3.7': ('adminCenter', 'restrictThirdPartyStorage', True),
'1.3.9': ('adminCenter', 'restrictSharedBookings', True),
'1.3.3': ('adminCenter', 'externalCalendarSharing', 'Disabled'),
# --- Section 5: Entra ID ---
'5.1.2.2': ('entraId', 'blockUserConsent', True),
'5.1.2.3': ('entraId', 'blockTenantCreation', True),
'5.1.2.4': ('entraId', 'restrictAdminCenterAccess', True),
'5.1.2.6': ('entraId', 'disableLinkedIn', True),
'5.1.3.1': ('entraId', 'blockSecurityGroupCreation', True),
'5.1.3.4': ('entraId', 'blockM365GroupCreation', True),
'5.1.4.1': ('entraId', 'restrictDeviceJoin', True),
'5.1.4.2': ('entraId', 'maxDevicesPerUser', 5),
'5.1.4.3': ('entraId', 'gaLocalAdminDisabled', True),
'5.1.4.4': ('entraId', 'limitLocalAdminAssignment', True),
'5.1.4.5': ('entraId', 'enableLAPS', True),
'5.1.4.6': ('entraId', 'restrictBitLockerRecovery', True),
'5.1.5.1': ('entraId', 'blockUserConsent', True),
'5.1.5.2': ('entraId', 'enableAdminConsentWorkflow', True),
'5.1.5.3': ('entraId', 'blockPasswordCredentials', True),
'5.1.5.4': ('entraId', 'maxPasswordLifetimeDays', 180),
'5.1.5.5': ('entraId', 'systemGeneratedPasswords', True),
'5.1.5.6': ('entraId', 'maxCertificateLifetimeDays', 180),
'5.1.6.1': ('entraId', 'restrictCollaborationDomains', True),
'5.1.6.2': ('entraId', 'restrictGuestAccess', True),
'5.1.6.3': ('entraId', 'limitGuestInvitations', True),
'5.1.8.1': ('entraId', 'enablePasswordHashSync', True),
'5.2.3.1': ('entraId', 'authenticatorNumberMatching', True),
'5.2.3.4': ('entraId', 'mfaCapableUsers', True),
'5.2.3.5': ('entraId', 'disableWeakAuthMethods', True),
'5.2.3.6': ('entraId', 'systemPreferredMFA', True),
'5.2.3.7': ('entraId', 'disableEmailOTP', True),
'5.2.3.8': ('entraId', 'lockoutThreshold', 10),
'5.2.3.9': ('entraId', 'lockoutDurationSeconds', 60),
'5.2.3.10': ('entraId', 'disableAuthenticatorCompanionApps', True),
'5.3.1': ('entraId', 'pimRoleActivationRequired', True),
'5.3.2': ('entraId', 'accessReviewsForGuests', True),
'5.3.3': ('entraId', 'accessReviewsForPrivilegedRoles', True),
'5.3.4': ('entraId', 'requireApprovalForGAActivation', True),
'5.3.5': ('entraId', 'requireApprovalForPRAActivation', True),
# --- Section 6: Exchange ---
'6.1.1': ('exchange', 'enableMailboxAuditOrgWide', True),
'6.1.2': ('exchange', 'configureMailboxAuditActions', True),
'6.1.3': ('exchange', 'disableAuditBypass', True),
'6.2.1': ('exchange', 'blockExternalForwarding', True),
'6.2.2': ('exchange', 'noDomainWhitelistTransportRules', True),
'6.2.3': ('exchange', 'enableExternalSenderBanner', True),
'6.3.1': ('exchange', 'blockOutlookAddIns', True),
'6.3.2': ('exchange', 'disablePersonalEmailAccounts', True),
'6.5.1': ('exchange', 'enableModernAuthExchange', True),
'6.5.2': ('exchange', 'enableMailTips', True),
'6.5.3': ('exchange', 'restrictAdditionalStorageProviders', True),
'6.5.4': ('exchange', 'disableSMTPAuth', True),
'6.5.5': ('exchange', 'rejectDirectSend', True),
'1.2.2': ('exchange', 'blockSharedMailboxSignIn', True),
'2.1.12': ('exchange', 'connectionFilterIPAllowListEmpty', True),
'2.1.13': ('exchange', 'connectionFilterSafeListOff', True),
'2.1.14': ('exchange', 'inboundAntiSpamNoAllowedDomains', True),
'2.1.15': ('exchange', 'outboundAntiSpamLimits', True),
# --- Section 7: SharePoint ---
'7.2.1': ('sharePoint', 'requireModernAuthSharePoint', True),
'7.2.2': ('sharePoint', 'enableAADB2BIntegration', True),
'7.2.3': ('sharePoint', 'sharePointExternalSharing', 'Disabled'),
'7.2.4': ('sharePoint', 'oneDriveExternalSharing', 'Disabled'),
'7.2.5': ('sharePoint', 'preventGuestResharing', True),
'7.2.6': ('sharePoint', 'restrictSharePointExternalSharing', True),
'7.2.7': ('sharePoint', 'restrictLinkSharing', True),
'7.2.8': ('sharePoint', 'restrictSharingBySecurityGroup', True),
'7.2.9': ('sharePoint', 'guestAccessExpirationDays', 30),
'7.2.10': ('sharePoint', 'restrictReauthenticationVerificationCode', True),
'7.2.11': ('sharePoint', 'defaultSharingLinkPermission', 'View'),
'7.3.1': ('sharePoint', 'disallowInfectedFileDownload', True),
# --- Section 8: Teams ---
'8.1.1': ('teams', 'restrictExternalFileSharing', True),
'8.1.2': ('teams', 'blockChannelEmail', True),
'8.2.1': ('teams', 'restrictExternalDomains', True),
'8.2.2': ('teams', 'disableUnmanagedUserCommunication', True),
'8.2.3': ('teams', 'blockExternalUserInitiation', True),
'8.2.4': ('teams', 'blockTrialTenantCommunication', True),
'8.5.1': ('teams', 'allowAnonymousUsersToJoinMeeting', False),
'8.5.2': ('teams', 'allowAnonymousUsersToStartMeeting', False),
'8.5.3': ('teams', 'orgOnlyBypassLobby', True),
'8.5.4': ('teams', 'dialInCantBypassLobby', True),
'8.5.5': ('teams', 'noAnonymousMeetingChat', True),
'8.5.6': ('teams', 'onlyOrganizersCanPresent', True),
'8.5.7': ('teams', 'noExternalControl', True),
'8.5.8': ('teams', 'externalMeetingChatOff', True),
'8.5.9': ('teams', 'meetingRecordingOffByDefault', True),
'8.6.1': ('teams', 'enableSecurityConcernsReporting', True),
# --- Section 9: Power BI ---
'9.1.1': ('powerBI', 'restrictGuestAccess', True),
'9.1.2': ('powerBI', 'restrictExternalInvitations', True),
'9.1.3': ('powerBI', 'restrictGuestContentAccess', True),
'9.1.4': ('powerBI', 'restrictPublishToWeb', True),
'9.1.5': ('powerBI', 'disableRPythonVisuals', True),
'9.1.6': ('powerBI', 'enableSensitivityLabels', True),
'9.1.7': ('powerBI', 'restrictShareableLinks', True),
'9.1.8': ('powerBI', 'restrictExternalDataSharing', True),
'9.1.9': ('powerBI', 'blockResourceKeyAuth', True),
'9.1.10': ('powerBI', 'restrictServicePrincipalAPIAccess', True),
'9.1.11': ('powerBI', 'blockServicePrincipalProfiles', True),
'9.1.12': ('powerBI', 'restrictServicePrincipalWorkspaceCreation', True),
# --- Section 3: Purview ---
'3.1.1': ('purview', 'enableAuditLogSearch', True),
}
# Defender policy mappings
defender_policies = {
'2.1.1': ('safeLinks', {
'name': 'SafeLinks-Default',
'enabled': True,
'trackClicks': True,
'allowClickThrough': False,
'scanUrls': True,
'enableForInternalSenders': True,
}),
'2.1.2': ('antiMalware', {
'name': 'AntiMalware-Default',
'enabled': True,
'enableInternalNotifications': True,
'fileTypes': ['ace', 'ani', 'app', 'docm', 'exe', 'jar', 'jnlp', 'msi', 'ps1', 'scr', 'vbs', 'wsf'],
}),
'2.1.3': ('antiMalware', {
'name': 'AntiMalware-InternalNotify',
'enabled': True,
'enableInternalNotifications': True,
}),
'2.1.4': ('safeAttachments', {
'name': 'SafeAttachments-Default',
'enabled': True,
'action': 'Block',
'quarantineMessages': True,
}),
'2.1.5': ('safeAttachments', {
'name': 'SafeAttachments-SPO-Teams',
'enabled': True,
'action': 'Block',
'enableForSharePoint': True,
'enableForTeams': True,
}),
'2.1.6': ('antiSpam', {
'name': 'AntiSpam-Notify-Admins',
'enabled': True,
'notifyAdmins': True,
}),
'2.1.7': ('antiPhish', {
'name': 'AntiPhish-Default',
'enabled': True,
'enableMailboxIntelligence': True,
'enableSpoofIntelligence': True,
'mailboxIntelligenceProtectionAction': 'Quarantine',
}),
'2.1.11': ('antiMalware', {
'name': 'AntiMalware-Comprehensive',
'enabled': True,
'enableFileFilter': True,
}),
'2.4.1': ('priorityAccount', {'enabled': True}),
'2.4.2': ('priorityAccount', {'strictProtection': True}),
'2.4.4': ('zap', {'enabledForTeams': True}),
}
# Draft YAML blocks for tenant-specific controls (commented out)
draft_blocks = {
'1.1.3': [
" # ASSESSMENT-ONLY: Report current global admin count; cannot auto-remediate",
" # assessment:",
" # control: \"1.1.3\"",
" # name: \"GlobalAdminCount\"",
" # minAdmins: 2",
" # maxAdmins: 4",
],
'1.1.4': [
" # ASSESSMENT-ONLY: Report admin license footprint; cannot auto-remediate",
" # assessment:",
" # control: \"1.1.4\"",
" # name: \"AdminLicenseFootprint\"",
" # allowedSkus: [\"AAD_PREMIUM_P2\", \"ENTERPRISEPACK\", \"SPE_E5\"]",
],
'1.2.1': [
" # ASSESSMENT-ONLY: Review public groups; cannot auto-remediate",
" # assessment:",
" # control: \"1.2.1\"",
" # name: \"PublicGroupReview\"",
" # visibilityFilter: \"Public\"",
],
'3.2.1': [
" # DRAFT: Uncomment and customize DLP policies for your environment",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Financial-Data\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"Exchange\"",
" # - type: \"SharePoint\"",
" # - type: \"OneDrive\"",
" # rules:",
" # - name: \"Detect-Credit-Cards\"",
" # sensitiveInfoTypes: [\"Credit Card Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
" # - name: \"CIS-DLP-PII\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Detect-SSN\"",
" # sensitiveInfoTypes: [\"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.2.2': [
" # DRAFT: Uncomment and customize Teams DLP policy",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Teams\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Teams-Detect-PII\"",
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.2.3': [
" # DRAFT: Uncomment and customize Copilot DLP policy",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Copilot\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Copilot-Detect-Sensitive\"",
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.3.1': [
" # DRAFT: Uncomment and customize sensitivity labels for your organization",
" # sensitivityLabels:",
" # - name: \"Internal\"",
" # displayName: \"Internal\"",
" # priority: 1",
" # enabled: true",
" # labelAction: \"Encrypt\"",
" # - name: \"Confidential\"",
" # displayName: \"Confidential\"",
" # priority: 2",
" # enabled: true",
" # labelAction: \"Encrypt\"",
" # sensitivityLabelPolicies:",
" # - name: \"CIS-Label-Policy-Default\"",
" # enabled: true",
" # labels: [\"Internal\", \"Confidential\"]",
" # defaultLabel: \"Internal\"",
],
}
def format_val(val):
if isinstance(val, str):
return f'"{val}"'
elif isinstance(val, bool):
return str(val).lower()
elif isinstance(val, list):
return '[' + ', '.join(f'"{v}"' for v in val) + ']'
return str(val)
def write_simple_section(sec_num, sec_name, sec_recs):
if not sec_recs:
return
lines.append("")
lines.append(f" # ===============================================================")
lines.append(f" # Section {sec_num}: {sec_name}")
lines.append(f" # ===============================================================")
lines.append(f" {sec_name}:")
for r in sec_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
# Filter by level/license
if not matches_filter(profiles, level_filter, license_filter):
continue
# Skip CA policies — they are handled in the conditionalAccess section
if ctrl.startswith('5.2.2.'):
continue
# Skip on-prem AD password protection — hybrid only
if ctrl == '5.2.3.3':
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # NOTE: Hybrid-only control — requires on-premises Active Directory")
continue
# Banned passwords — add inline with external file support
if ctrl == '5.2.3.2':
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" # Option A: Inline list")
lines.append(f" bannedPasswords:")
lines.append(f" - \"Contoso\"")
lines.append(f" - \"Password\"")
lines.append(f" - \"Welcome\"")
lines.append(f" - \"Admin\"")
lines.append(f" - \"Login\"")
lines.append(f" - \"Microsoft\"")
lines.append(f" - \"Office365\"")
lines.append(f" # Option B: External file (one password per line)")
lines.append(f" # bannedPasswordsFile: \"./banned-passwords.txt\"")
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
rem = r.get('remediation', '')
hint = rem[:120] + '...' if len(rem) > 120 else rem
if hint:
lines.append(f" # HINT: {hint}")
lines.append(f" # TODO: Implement manually per PDF instructions")
continue
if ctrl in simple_mappings:
sec, key, val = simple_mappings[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {key}: {format_val(val)}")
elif ctrl in draft_blocks:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
for line in draft_blocks[ctrl]:
lines.append(line)
else:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
# Write non-defender, non-CA sections
for sec_num in ['1', '5', '6', '7', '8', '9', '3']:
sec_name = section_names[sec_num]
sec_recs = [r for r in recommendations if r['control'].split('.')[0] == sec_num]
write_simple_section(sec_num, sec_name, sec_recs)
# Defender section (with proper policy structures)
def_recs = [r for r in recommendations if r['control'].split('.')[0] == '2']
if def_recs:
lines.append("")
lines.append(" # ===============================================================")
lines.append(" # Section 2: Defender for Office 365")
lines.append(" # ===============================================================")
lines.append(" defender:")
for r in def_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
if not matches_filter(profiles, level_filter, license_filter):
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
continue
if ctrl in defender_policies:
policy_type, policy_def = defender_policies[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {policy_type}:")
for k, v in policy_def.items():
lines.append(f" {k}: {format_val(v)}")
elif ctrl in ['2.1.8', '2.1.9', '2.1.10']:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # NOTE: DNS-level control — configure via DNS provider, not M365 tenant")
elif ctrl in simple_mappings:
sec, key, val = simple_mappings[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {key}: {format_val(val)}")
else:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
# Conditional Access section
ca_recs = [r for r in recommendations if r['control'].startswith('5.2.2.')]
if ca_recs:
lines.append("")
lines.append(" # ===============================================================")
lines.append(" # Section 5.2.2: Conditional Access")
lines.append(" # ===============================================================")
lines.append(" conditionalAccess:")
lines.append(" reportOnly: true")
lines.append(" breakGlassGroup: \"CIS-BreakGlass\"")
lines.append(" policies:")
for r in ca_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
if not matches_filter(profiles, level_filter, license_filter):
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
continue
name = re.sub(r'[^a-zA-Z0-9\s]', '', title)
name = re.sub(r'\s+', '-', name)
name = re.sub(r'-+', '-', name)
name = name[:55].strip('-')
lines.append(f" - name: \"{name}\"")
lines.append(f" cisControl: \"{ctrl}\"")
lines.append(f" description: \"{title}\"")
lines.append(f" state: enabledForReportingButNotEnforced")
lines.append(f" conditions:")
lines.append(f" applications:")
t = title.lower()
if 'intune enrollment' in t:
lines.append(f" includeApplications: [\"0000000a-0000-0000-c000-000000000000\"]")
elif 'register security' in t:
lines.append(f" includeUserActions: [\"urn:user:registersecurityinfo\"]")
else:
lines.append(f" includeApplications: [\"All\"]")
lines.append(f" users:")
if 'admin' in t or 'administrator' in t:
lines.append(f" includeRoles:")
lines.append(f" - \"Global Administrator\"")
lines.append(f" - \"Privileged Role Administrator\"")
lines.append(f" - \"Security Administrator\"")
lines.append(f" - \"Exchange Administrator\"")
lines.append(f" - \"SharePoint Administrator\"")
lines.append(f" - \"Conditional Access Administrator\"")
lines.append(f" - \"Application Administrator\"")
lines.append(f" - \"Cloud Application Administrator\"")
lines.append(f" - \"User Administrator\"")
lines.append(f" - \"Helpdesk Administrator\"")
lines.append(f" - \"Billing Administrator\"")
lines.append(f" - \"Authentication Administrator\"")
lines.append(f" - \"Password Administrator\"")
lines.append(f" - \"Global Reader\"")
else:
lines.append(f" includeUsers: [\"All\"]")
if 'legacy' in t:
lines.append(f" clientAppTypes: [\"exchangeActiveSync\", \"other\"]")
elif 'device code' in t:
lines.append(f" authenticationFlows:")
lines.append(f" deviceCodeFlow:")
lines.append(f" isEnabled: true")
elif 'sign-in risk' in t or 'risk' in t:
lines.append(f" signInRiskLevels: [\"medium\", \"high\"]")
elif 'named location' in t or 'geographic' in t:
lines.append(f" # TODO: Define named locations in Entra admin center")
lines.append(f" grantControls:")
if 'block' in t and ('legacy' in t or 'device code' in t or 'risk' in t or 'authentication transfer' in t):
lines.append(f" builtInControls: [\"block\"]")
lines.append(f" operator: \"OR\"")
elif 'mfa' in t and 'phishing-resistant' in t:
lines.append(f" builtInControls: [\"authenticationStrength\"]")
lines.append(f" authenticationStrength:")
lines.append(f" id: \"00000000-0000-0000-0000-000000000004\"")
lines.append(f" operator: \"OR\"")
elif 'mfa' in t or 'multifactor' in t or 'reauthentication' in t or 're-authentication' in t:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
elif 'managed device' in t:
lines.append(f" builtInControls: [\"compliantDevice\", \"domainJoinedDevice\"]")
lines.append(f" operator: \"OR\"")
elif 'token protection' in t:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
lines.append(f" # TODO: Enable Token Protection via Authentication Strength policy")
else:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
if 'sign-in frequency' in t or 'browser' in t or 'persistent' in t:
lines.append(f" sessionControls:")
lines.append(f" signInFrequency:")
lines.append(f" value: 12")
lines.append(f" type: hours")
lines.append(f" isEnabled: true")
lines.append(f" persistentBrowser:")
lines.append(f" mode: never")
lines.append(f" isEnabled: true")
return '\n'.join(lines) + '\n'
def main():
if len(sys.argv) < 3:
print("Usage: _ConvertFrom-CISPDF.py <pdf_path> <output_path> [prefix] [level] [license]")
print(" level: L1 | L2 | Both (default)")
print(" license: E3 | E5 | Both (default)")
sys.exit(1)
pdf_path = sys.argv[1]
output_path = sys.argv[2]
prefix = sys.argv[3] if len(sys.argv) > 3 else "CIS-v7-"
level_filter = sys.argv[4] if len(sys.argv) > 4 else "Both"
license_filter = sys.argv[5] if len(sys.argv) > 5 else "Both"
print(f"Parsing PDF: {pdf_path}")
recommendations = parse_pdf(pdf_path)
auto = sum(1 for r in recommendations if r['status'] == 'Automated')
manual = sum(1 for r in recommendations if r['status'] == 'Manual')
print(f"Found {len(recommendations)} unique recommendations")
print(f" Automated: {auto}")
print(f" Manual: {manual}")
print(f"\nGenerating YAML (level={level_filter}, license={license_filter})...")
yaml = generate_yaml(recommendations, prefix, level_filter, license_filter)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(yaml)
print(f"Written: {output_path}")
print(f"Total lines: {len(yaml.splitlines())}")
if __name__ == '__main__':
main()
+1104
View File
File diff suppressed because it is too large Load Diff