release: v4.1.0 — restructure entry points, add CIS baselines, reporting tools and fzf hints
- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root; Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/ - Add AGENTS.md with project architecture, entry points, and security notes - Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts - Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport, Export-ObjectInventoryReport) and CA wizard helpers - Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath, and optimized group loading - Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry - Update Extensions for Settings Catalog definition auto-export - Update README with v4.1.0, new entry points and script catalog - Bump VERSION to 4.1.0 - Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports, Settings.json and IntuneManagement.log
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
baseline:
|
||||
name: Generated-ConditionalAccess-Baseline
|
||||
conflictResolution: Skip
|
||||
whatIf: false
|
||||
tenantConfig:
|
||||
conditionalAccess:
|
||||
reportOnly: false
|
||||
breakGlassGroup: CQRE-BreakGlass
|
||||
policies:
|
||||
- name: CQRE-CA0901-AllUsers-AllApps-BlockLegacyAuth
|
||||
description: Block all legacy authentication protocols
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
clientAppTypes:
|
||||
- exchangeActiveSync
|
||||
- other
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- block
|
||||
operator: OR
|
||||
- name: CQRE-CA1901-AllUsers-SecurityInfo-RequireTrustedLocation
|
||||
description: Require trusted location or managed device to register security
|
||||
info
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeUserActions:
|
||||
- urn:user:registersecurityinfo
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- compliantDevice
|
||||
- domainJoinedDevice
|
||||
operator: OR
|
||||
- name: CQRE-CA0902-AllUsers-AllApps-BlockUnsupportedPlatforms
|
||||
description: Block sign-ins from unknown or unsupported device platforms
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
platforms:
|
||||
includePlatforms:
|
||||
- all
|
||||
excludePlatforms:
|
||||
- android
|
||||
- iOS
|
||||
- windows
|
||||
- macOS
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- block
|
||||
operator: OR
|
||||
- name: CQRE-CA0903-AllUsers-AllApps-BlockDeviceCodeFlow
|
||||
description: Block device-code authentication flow
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
authenticationFlows:
|
||||
deviceCodeFlow:
|
||||
isEnabled: true
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- block
|
||||
operator: OR
|
||||
- name: CQRE-CA1902-AllUsers-AllApps-RequireMFAUntrusted
|
||||
description: Require MFA only from untrusted locations
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
locations:
|
||||
includeLocations:
|
||||
- All
|
||||
excludeLocations:
|
||||
- AllTrusted
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- mfa
|
||||
operator: OR
|
||||
- name: CQRE-CA1903-AllUsers-AllApps-RequireCompliantDevice
|
||||
description: Require compliant or hybrid-joined device for all users
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- compliantDevice
|
||||
- domainJoinedDevice
|
||||
operator: OR
|
||||
- name: CQRE-CA1904-AllUsers-AllApps-BlockUntrustedLocations
|
||||
description: Block sign-ins from untrusted locations
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
locations:
|
||||
includeLocations:
|
||||
- All
|
||||
excludeLocations:
|
||||
- AllTrusted
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- block
|
||||
operator: OR
|
||||
- name: CQRE-CA0904-AllUsers-AllApps-RequireMFAForRiskySignIns
|
||||
description: Require MFA for medium/high risk sign-ins
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
signInRiskLevels:
|
||||
- medium
|
||||
- high
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- mfa
|
||||
operator: OR
|
||||
- name: CQRE-CA0905-AllUsers-AllApps-ForcePasswordChangeHighRiskUsers
|
||||
description: Force password change for high-risk users
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
userRiskLevels:
|
||||
- high
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- passwordChange
|
||||
operator: OR
|
||||
- name: CQRE-CA0906-AllUsers-AllApps-BlockInsiderRisk
|
||||
description: Block sessions flagged by Purview Insider Risk
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
insiderRiskLevels:
|
||||
- elevated
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- block
|
||||
operator: OR
|
||||
- name: CQRE-CA2901-Admins-AllApps-RequireCompliantDevice
|
||||
description: Administrators must use compliant or hybrid-joined devices
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeRoles: &id001
|
||||
- Global Administrator
|
||||
- Privileged Role Administrator
|
||||
- Security Administrator
|
||||
- Exchange Administrator
|
||||
- SharePoint Administrator
|
||||
- Conditional Access Administrator
|
||||
- Application Administrator
|
||||
- Cloud Application Administrator
|
||||
- User Administrator
|
||||
- Helpdesk Administrator
|
||||
- Billing Administrator
|
||||
- Authentication Administrator
|
||||
- Password Administrator
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- compliantDevice
|
||||
- domainJoinedDevice
|
||||
operator: OR
|
||||
- name: CQRE-CA2902-Admins-AllApps-BlockUntrustedLocations
|
||||
description: Administrators can only sign in from trusted locations
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeRoles: *id001
|
||||
locations:
|
||||
includeLocations:
|
||||
- All
|
||||
excludeLocations:
|
||||
- AllTrusted
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- block
|
||||
operator: OR
|
||||
- name: CQRE-CA2903-Admins-AllApps-NoPersistentSession
|
||||
description: No persistent browser sessions for admins; re-auth every 12h
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeRoles: *id001
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- mfa
|
||||
operator: OR
|
||||
sessionControls:
|
||||
signInFrequency:
|
||||
value: 12
|
||||
type: hours
|
||||
isEnabled: true
|
||||
persistentBrowser:
|
||||
mode: never
|
||||
isEnabled: true
|
||||
- name: CQRE-CA3901-Guests-AllApps-RequireMFA
|
||||
description: Require MFA for guest and external users
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeGuestsOrExternalUsers:
|
||||
guestTypes:
|
||||
- internalGuest
|
||||
- b2bCollaborationGuest
|
||||
- b2bCollaborationMember
|
||||
- b2bDirectConnectUser
|
||||
externalTenants:
|
||||
membershipKind: all
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- mfa
|
||||
operator: OR
|
||||
- name: CQRE-CA3902-Guests-AllApps-RequireTermsOfUse
|
||||
description: Require guests to accept terms of use
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- All
|
||||
users:
|
||||
includeGuestsOrExternalUsers:
|
||||
guestTypes:
|
||||
- internalGuest
|
||||
- b2bCollaborationGuest
|
||||
- b2bCollaborationMember
|
||||
- b2bDirectConnectUser
|
||||
externalTenants:
|
||||
membershipKind: all
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- termsOfUse
|
||||
operator: OR
|
||||
- name: CQRE-CA4901-AllUsers-O365-AppEnforcedRestrictions
|
||||
description: Enforce application restrictions for Office 365
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- Office365
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- mfa
|
||||
operator: OR
|
||||
sessionControls:
|
||||
applicationEnforcedRestrictions:
|
||||
isEnabled: true
|
||||
- name: CQRE-CA4902-AllUsers-AzureMgmt-RequireMFA
|
||||
description: Require MFA for Azure management portal
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- 797f4846-ba00-4fd7-ba43-dac1f8f63013
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- mfa
|
||||
operator: OR
|
||||
- name: CQRE-CA4903-AllUsers-AdminPortals-RequireMFA
|
||||
description: Require MFA for Microsoft admin portals
|
||||
state: enabled
|
||||
conditions:
|
||||
applications:
|
||||
includeApplications:
|
||||
- 797f4846-ba00-4fd7-ba43-dac1f8f63013
|
||||
- c44b4083-3bb0-49c1-b47d-974e53cbdf3c
|
||||
- 1b730954-1685-4b74-9bfd-dac224a7b894
|
||||
- 00000003-0000-0ff1-ce00-000000000000
|
||||
- 00000003-0000-0000-c000-000000000000
|
||||
- de8bc8b5-d9f9-48b1-a8ad-b748da725064
|
||||
- 00000002-0000-0ff1-ce00-000000000000
|
||||
- 66a88757-258c-4c72-893c-3e8bed4d6899
|
||||
users:
|
||||
includeUsers:
|
||||
- All
|
||||
grantControls:
|
||||
builtInControls:
|
||||
- mfa
|
||||
operator: OR
|
||||
@@ -0,0 +1,74 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Converts a CIS M365 Benchmark v7.0.0 PDF into a YAML baseline manifest.
|
||||
|
||||
.DESCRIPTION
|
||||
Extracts text from the draft CIS PDF, parses recommendations, and generates
|
||||
a CISM365-v7.yaml baseline file ready for Deploy-CISM365Baseline.ps1.
|
||||
|
||||
Prerequisites:
|
||||
- Python 3 with pypdf installed (script will create venv if needed)
|
||||
- The draft PDF at the specified path
|
||||
|
||||
.PARAMETER PdfPath
|
||||
Path to the CIS M365 v7.0.0 draft PDF.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Path for the generated YAML file. Defaults to ./Baselines/CISM365-v7-Generated.yaml
|
||||
|
||||
.PARAMETER Prefix
|
||||
Optional naming prefix for all generated policies.
|
||||
|
||||
.EXAMPLE
|
||||
./Scripts/ConvertFrom-CISPDF.ps1 -PdfPath ~/Downloads/DRAFT_CIS_Microsoft_365_Foundations_Benchmark_v7.0.0.pdf
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PdfPath,
|
||||
|
||||
[Parameter()]
|
||||
[string]$OutputPath = "$PSScriptRoot/../Baselines/CISM365-v7-Generated.yaml",
|
||||
|
||||
[Parameter()]
|
||||
[string]$Prefix = "CIS-v7-",
|
||||
|
||||
[Parameter()]
|
||||
[ValidateSet('L1','L2','Both')]
|
||||
[string]$Level = 'Both',
|
||||
|
||||
[Parameter()]
|
||||
[ValidateSet('E3','E5','Both')]
|
||||
[string]$License = 'Both'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Resolve paths
|
||||
$pdfPathResolved = Resolve-Path $PdfPath | Select-Object -ExpandProperty Path
|
||||
$outputPathResolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
|
||||
|
||||
# Ensure Python venv exists
|
||||
$venvPath = "$PSScriptRoot/../.venv-pdf"
|
||||
$pythonExe = "$venvPath/bin/python3"
|
||||
|
||||
if (-not (Test-Path $pythonExe)) {
|
||||
Write-Host "Creating Python virtual environment..." -ForegroundColor Yellow
|
||||
python3 -m venv $venvPath
|
||||
& "$venvPath/bin/pip" install pypdf | Out-Null
|
||||
}
|
||||
|
||||
$pyScript = "$PSScriptRoot/_ConvertFrom-CISPDF.py"
|
||||
if (-not (Test-Path $pyScript)) {
|
||||
throw "Python converter script not found: $pyScript"
|
||||
}
|
||||
|
||||
Write-Host "Converting PDF to YAML baseline..." -ForegroundColor Cyan
|
||||
& $pythonExe $pyScript $pdfPathResolved $outputPathResolved $Prefix $Level $License
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Done. Review the generated file before deploying." -ForegroundColor Green
|
||||
} else {
|
||||
throw "PDF conversion failed."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ param(
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[switch]$WhatIf
|
||||
[switch]$WhatIf,
|
||||
|
||||
[string]$ReportPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -375,11 +377,13 @@ if($effectiveWhatIf) { Write-Host "*** DRY-RUN MODE ENABLED ***" -ForegroundColo
|
||||
|
||||
#region Resolve / create groups
|
||||
$groupCache = @{}
|
||||
Write-Host "`nLoading group directory..." -ForegroundColor Cyan
|
||||
$allGroupsData = (Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages).value
|
||||
|
||||
if($baseline.ContainsKey("groups") -and $baseline["groups"])
|
||||
{
|
||||
Write-Host "`nResolving groups..." -ForegroundColor Cyan
|
||||
$existingGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
|
||||
$existingGroups = $existingGroupsResp.value
|
||||
Write-Host "Resolving baseline groups..." -ForegroundColor Cyan
|
||||
$existingGroups = $allGroupsData
|
||||
|
||||
foreach($grpDef in $baseline["groups"])
|
||||
{
|
||||
@@ -412,9 +416,7 @@ if($baseline.ContainsKey("groups") -and $baseline["groups"])
|
||||
#endregion
|
||||
|
||||
#region Pre-load all existing groups for assignment resolution
|
||||
Write-Host "`nPre-loading group directory..." -ForegroundColor Cyan
|
||||
$allGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
|
||||
foreach($g in $allGroupsResp.value)
|
||||
foreach($g in $allGroupsData)
|
||||
{
|
||||
if(-not $groupCache.ContainsKey($g.displayName))
|
||||
{
|
||||
@@ -432,6 +434,7 @@ $stats = @{
|
||||
Failed = 0
|
||||
Assigned = 0
|
||||
}
|
||||
$policyResults = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
{
|
||||
@@ -482,6 +485,9 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
$objectId = $null
|
||||
$shouldAssign = $false
|
||||
|
||||
$outcomeStatus = $null
|
||||
$outcomeObjectId = $null
|
||||
|
||||
if($existingObj)
|
||||
{
|
||||
Write-Host " Existing object found: $($existingObj.id)" -ForegroundColor Yellow
|
||||
@@ -495,22 +501,49 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
$objectId = $existingObj.id
|
||||
$shouldAssign = $true # still apply assignments to existing object
|
||||
$stats.Skipped++
|
||||
$outcomeStatus = "Skipped"; $outcomeObjectId = $existingObj.id
|
||||
}
|
||||
elseif($conflictResolution -eq "Update")
|
||||
{
|
||||
if($effectiveWhatIf)
|
||||
{
|
||||
Write-Host " [WHATIF] Would PATCH existing object $($existingObj.id)" -ForegroundColor Magenta
|
||||
$outcomeStatus = "WhatIf-Update"
|
||||
}
|
||||
else
|
||||
{
|
||||
$patchBody = $policyObj | Select-Object * | ConvertTo-Json -Depth 50
|
||||
$null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content $patchBody
|
||||
Write-Host " Updated existing object." -ForegroundColor Green
|
||||
$outcomeStatus = "Updated"
|
||||
}
|
||||
$objectId = $existingObj.id
|
||||
$shouldAssign = $true
|
||||
$stats.Updated++
|
||||
$outcomeObjectId = $existingObj.id
|
||||
}
|
||||
elseif($conflictResolution -eq "Merge")
|
||||
{
|
||||
if($effectiveWhatIf)
|
||||
{
|
||||
Write-Host " [WHATIF] Would PATCH (merge) existing object $($existingObj.id)" -ForegroundColor Magenta
|
||||
$outcomeStatus = "WhatIf-Merge"
|
||||
}
|
||||
else
|
||||
{
|
||||
$mergeBody = @{}
|
||||
foreach($prop in $policyObj.PSObject.Properties)
|
||||
{
|
||||
$mergeBody[$prop.Name] = $prop.Value
|
||||
}
|
||||
$null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content ($mergeBody | ConvertTo-Json -Depth 50)
|
||||
Write-Host " Merged into existing object." -ForegroundColor Green
|
||||
$outcomeStatus = "Merged"
|
||||
}
|
||||
$objectId = $existingObj.id
|
||||
$shouldAssign = $true
|
||||
$stats.Updated++
|
||||
$outcomeObjectId = $existingObj.id
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -521,6 +554,7 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
$objectId = "WHATIF-NEW"
|
||||
$shouldAssign = $true
|
||||
$stats.Created++
|
||||
$outcomeStatus = "WhatIf-Create"
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -530,6 +564,7 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
Write-Host " Created: $objectId" -ForegroundColor Green
|
||||
$shouldAssign = $true
|
||||
$stats.Created++
|
||||
$outcomeStatus = "Created"; $outcomeObjectId = $newObj.id
|
||||
|
||||
# Secondary settings upload (EndpointSecurity / DeviceManagementIntents)
|
||||
if($typeMeta.SettingsAPI)
|
||||
@@ -556,11 +591,28 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
|
||||
Invoke-DeployAssignments -ObjectId $objectId -TypeMeta $typeMeta -Assignments $policyDef["assignments"] -GroupCache $groupCache -WhatIf:$effectiveWhatIf
|
||||
$stats.Assigned++
|
||||
}
|
||||
|
||||
$policyResults.Add([PSCustomObject]@{
|
||||
PolicyName = $mutatedName
|
||||
Type = $typeName
|
||||
SourcePath = $sourcePath
|
||||
ObjectId = $outcomeObjectId
|
||||
Outcome = $outcomeStatus
|
||||
Error = $null
|
||||
})
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Warning "Failed to deploy policy '$sourcePath': $_"
|
||||
$stats.Failed++
|
||||
$policyResults.Add([PSCustomObject]@{
|
||||
PolicyName = $mutatedName
|
||||
Type = $typeName
|
||||
SourcePath = $sourcePath
|
||||
ObjectId = $null
|
||||
Outcome = "Failed"
|
||||
Error = $_.Exception.Message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,4 +631,49 @@ if($effectiveWhatIf)
|
||||
{
|
||||
Write-Host "`n*** This was a dry-run (WhatIf). No changes were made. ***" -ForegroundColor Magenta
|
||||
}
|
||||
|
||||
if($policyResults.Count -gt 0)
|
||||
{
|
||||
$resolvedReportPath = if($ReportPath) { $ReportPath } else {
|
||||
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($baselinePathResolved)
|
||||
Join-Path (Split-Path -Parent $baselinePathResolved) "${baseName}_DeployReport_${ts}.csv"
|
||||
}
|
||||
$policyResults | Export-Csv -Path $resolvedReportPath -NoTypeInformation -Force
|
||||
Write-Host "Report : $resolvedReportPath" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
if(-not $effectiveWhatIf -and $policyResults.Count -gt 0)
|
||||
{
|
||||
$sha256 = [System.Security.Cryptography.SHA256]::Create()
|
||||
$manifestPolicies = $policyResults | Where-Object { $_.Outcome -in @("Created","Updated","Merged","Skipped") } | ForEach-Object {
|
||||
$hash = $null
|
||||
if($_.SourcePath -and (Test-Path $_.SourcePath))
|
||||
{
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.SourcePath)
|
||||
$hash = [System.BitConverter]::ToString($sha256.ComputeHash($bytes)) -replace '-',''
|
||||
}
|
||||
[ordered]@{
|
||||
policyName = $_.PolicyName
|
||||
type = $_.Type
|
||||
objectId = $_.ObjectId
|
||||
sourcePath = $_.SourcePath
|
||||
sourceHash = $hash
|
||||
outcome = $_.Outcome
|
||||
}
|
||||
}
|
||||
$sha256.Dispose()
|
||||
|
||||
$manifest = [ordered]@{
|
||||
baselineName = $baseline["name"]
|
||||
baselinePath = $baselinePathResolved
|
||||
tenantId = $TenantId
|
||||
deployedAt = (Get-Date -Format 'o')
|
||||
policies = @($manifestPolicies)
|
||||
}
|
||||
|
||||
$manifestPath = [System.IO.Path]::ChangeExtension($baselinePathResolved, "manifest.json")
|
||||
$manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -Force
|
||||
Write-Host "Manifest: $manifestPath" -ForegroundColor Cyan
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a policy assignment inventory CSV from Intune backup JSON files.
|
||||
|
||||
Walks every JSON file under the backup root and emits one row per assignment
|
||||
target (or one row per unassigned/not-exported object).
|
||||
|
||||
Output columns: PolicyType, ObjectName, ObjectType, AssignmentState,
|
||||
Intent, AssignmentTarget, TargetType, AssignmentFilter,
|
||||
FilterType, SourceFile
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
_GROUP_TARGET_TYPES = {
|
||||
"#microsoft.graph.groupAssignmentTarget",
|
||||
"#microsoft.graph.exclusionGroupAssignmentTarget",
|
||||
}
|
||||
|
||||
_EXCLUDED_DIRS = {"reports", "__archive__"}
|
||||
|
||||
FIELDNAMES = [
|
||||
"PolicyType",
|
||||
"ObjectName",
|
||||
"ObjectType",
|
||||
"AssignmentState",
|
||||
"Intent",
|
||||
"AssignmentTarget",
|
||||
"TargetType",
|
||||
"AssignmentFilter",
|
||||
"FilterType",
|
||||
"SourceFile",
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--root", required=True,
|
||||
help="Path to backup root (e.g. tenant-state/intune).")
|
||||
p.add_argument("--output", default="assignment-report.csv",
|
||||
help="Output CSV path (default: assignment-report.csv).")
|
||||
p.add_argument("--policy-type", action="append", default=[],
|
||||
help="Filter to specific top-level folder names (repeat or comma-separate).")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _safe(value: object) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def _resolve_target(target: dict) -> tuple[str, str]:
|
||||
"""Returns (display_name, target_type_short)."""
|
||||
ttype = _safe(target.get("@odata.type"))
|
||||
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
||||
return "All devices", ttype
|
||||
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
|
||||
return "All users", ttype
|
||||
if ttype in _GROUP_TARGET_TYPES:
|
||||
name = (target.get("groupDisplayName") or target.get("groupName")
|
||||
or target.get("groupId") or "Unresolved group")
|
||||
return _safe(name), ttype
|
||||
return (_safe(target.get("groupDisplayName") or target.get("displayName")
|
||||
or target.get("id")) or "Unknown target", ttype)
|
||||
|
||||
|
||||
def _infer_intent(assignment: dict, target_type: str) -> str:
|
||||
if "exclusion" in target_type.lower():
|
||||
return "Exclude"
|
||||
explicit = _safe(assignment.get("intent")).lower()
|
||||
if explicit in {"exclude"}:
|
||||
return "Exclude"
|
||||
return "Include"
|
||||
|
||||
|
||||
def _iter_rows(root: Path, policy_type_filter: set[str]) -> Iterator[dict]:
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
rel = path.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part in _EXCLUDED_DIRS for part in rel.parts):
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
policy_type = rel.parts[0] if rel.parts else ""
|
||||
if policy_type_filter and policy_type.lower() not in policy_type_filter:
|
||||
continue
|
||||
|
||||
object_name = (_safe(payload.get("displayName")) or _safe(payload.get("name"))
|
||||
or path.stem.split("__")[0])
|
||||
object_type = _safe(payload.get("@odata.type"))
|
||||
source = rel.as_posix()
|
||||
|
||||
base = {
|
||||
"PolicyType": policy_type,
|
||||
"ObjectName": object_name,
|
||||
"ObjectType": object_type,
|
||||
"SourceFile": source,
|
||||
}
|
||||
|
||||
assignments = payload.get("assignments")
|
||||
if not isinstance(assignments, list):
|
||||
yield {**base, "AssignmentState": "NotExported", "Intent": "",
|
||||
"AssignmentTarget": "Not exported in backup", "TargetType": "",
|
||||
"AssignmentFilter": "", "FilterType": ""}
|
||||
continue
|
||||
|
||||
valid = [a for a in assignments if isinstance(a, dict)]
|
||||
if not valid:
|
||||
yield {**base, "AssignmentState": "Unassigned", "Intent": "",
|
||||
"AssignmentTarget": "No assignments", "TargetType": "",
|
||||
"AssignmentFilter": "", "FilterType": ""}
|
||||
continue
|
||||
|
||||
for assignment in valid:
|
||||
target = assignment.get("target") or {}
|
||||
target_name, target_type = _resolve_target(target)
|
||||
intent = _infer_intent(assignment, target_type)
|
||||
yield {
|
||||
**base,
|
||||
"AssignmentState": "Assigned",
|
||||
"Intent": intent,
|
||||
"AssignmentTarget": target_name,
|
||||
"TargetType": target_type,
|
||||
"AssignmentFilter": _safe(target.get("deviceAndAppManagementAssignmentFilterId")),
|
||||
"FilterType": _safe(target.get("deviceAndAppManagementAssignmentFilterType")),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = Path(args.root).resolve()
|
||||
out_path = Path(args.output)
|
||||
|
||||
if not root.exists():
|
||||
raise SystemExit(f"Backup root not found: {root}")
|
||||
|
||||
policy_type_filter: set[str] = set()
|
||||
for raw in args.policy_type:
|
||||
for part in raw.split(","):
|
||||
v = part.strip().lower()
|
||||
if v:
|
||||
policy_type_filter.add(v)
|
||||
|
||||
rows = sorted(
|
||||
_iter_rows(root, policy_type_filter),
|
||||
key=lambda r: (r["PolicyType"].lower(), r["ObjectName"].lower(),
|
||||
r["AssignmentState"], r["Intent"].lower(),
|
||||
r["AssignmentTarget"].lower()),
|
||||
)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
print(f"Written {len(rows)} rows → {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate an object inventory CSV from Intune backup JSON files.
|
||||
|
||||
One row per JSON object. Includes assignment summary columns.
|
||||
|
||||
Output columns: PolicyType, ObjectName, ObjectType, ObjectId, Description,
|
||||
AssignmentState, AssignmentCount, IncludeTargets, ExcludeTargets,
|
||||
SourceFile
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
_EXCLUDED_DIRS = {"reports", "__archive__"}
|
||||
|
||||
_GROUP_TARGET_TYPES = {
|
||||
"#microsoft.graph.groupAssignmentTarget",
|
||||
"#microsoft.graph.exclusionGroupAssignmentTarget",
|
||||
}
|
||||
|
||||
FIELDNAMES = [
|
||||
"PolicyType",
|
||||
"ObjectName",
|
||||
"ObjectType",
|
||||
"ObjectId",
|
||||
"Description",
|
||||
"AssignmentState",
|
||||
"AssignmentCount",
|
||||
"IncludeTargets",
|
||||
"ExcludeTargets",
|
||||
"SourceFile",
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--root", required=True,
|
||||
help="Path to backup root (e.g. tenant-state/intune).")
|
||||
p.add_argument("--output", default="object-inventory.csv",
|
||||
help="Output CSV path (default: object-inventory.csv).")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _safe(value: object) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def _resolve_target_name(target: dict) -> tuple[str, str]:
|
||||
"""Returns (intent, display_name)."""
|
||||
ttype = _safe(target.get("@odata.type"))
|
||||
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
||||
return "include", "All devices"
|
||||
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
|
||||
return "include", "All users"
|
||||
if ttype == "#microsoft.graph.exclusionGroupAssignmentTarget":
|
||||
name = (_safe(target.get("groupDisplayName") or target.get("groupName")
|
||||
or target.get("groupId")) or "Unresolved group")
|
||||
return "exclude", name
|
||||
if ttype in _GROUP_TARGET_TYPES:
|
||||
name = (_safe(target.get("groupDisplayName") or target.get("groupName")
|
||||
or target.get("groupId")) or "Unresolved group")
|
||||
return "include", name
|
||||
return "include", (_safe(target.get("groupDisplayName") or target.get("id"))
|
||||
or "Unknown target")
|
||||
|
||||
|
||||
def _summarize_assignments(payload: dict) -> dict[str, str]:
|
||||
assignments = payload.get("assignments")
|
||||
if not isinstance(assignments, list):
|
||||
return {"AssignmentState": "NotExported", "AssignmentCount": "0",
|
||||
"IncludeTargets": "", "ExcludeTargets": ""}
|
||||
|
||||
valid = [a for a in assignments if isinstance(a, dict)]
|
||||
if not valid:
|
||||
return {"AssignmentState": "Unassigned", "AssignmentCount": "0",
|
||||
"IncludeTargets": "", "ExcludeTargets": ""}
|
||||
|
||||
include: list[str] = []
|
||||
exclude: list[str] = []
|
||||
for assignment in valid:
|
||||
target = assignment.get("target") or {}
|
||||
intent, name = _resolve_target_name(target)
|
||||
explicit = _safe(assignment.get("intent")).lower()
|
||||
if explicit == "exclude" or intent == "exclude":
|
||||
exclude.append(name)
|
||||
else:
|
||||
include.append(name)
|
||||
|
||||
return {
|
||||
"AssignmentState": "Assigned",
|
||||
"AssignmentCount": str(len(valid)),
|
||||
"IncludeTargets": "; ".join(sorted(set(include))),
|
||||
"ExcludeTargets": "; ".join(sorted(set(exclude))),
|
||||
}
|
||||
|
||||
|
||||
def _iter_rows(root: Path) -> Iterator[dict]:
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
rel = path.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part in _EXCLUDED_DIRS for part in rel.parts):
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
policy_type = rel.parts[0] if rel.parts else ""
|
||||
object_name = (_safe(payload.get("displayName")) or _safe(payload.get("name"))
|
||||
or path.stem.split("__")[0])
|
||||
assignment_summary = _summarize_assignments(payload)
|
||||
|
||||
yield {
|
||||
"PolicyType": policy_type,
|
||||
"ObjectName": object_name,
|
||||
"ObjectType": _safe(payload.get("@odata.type")),
|
||||
"ObjectId": _safe(payload.get("id")),
|
||||
"Description": _safe(payload.get("description")),
|
||||
"SourceFile": rel.as_posix(),
|
||||
**assignment_summary,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = Path(args.root).resolve()
|
||||
out_path = Path(args.output)
|
||||
|
||||
if not root.exists():
|
||||
raise SystemExit(f"Backup root not found: {root}")
|
||||
|
||||
rows = sorted(
|
||||
_iter_rows(root),
|
||||
key=lambda r: (r["PolicyType"].lower(), r["ObjectName"].lower()),
|
||||
)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
print(f"Written {len(rows)} rows → {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
|
||||
|
||||
Covers Settings Catalog policies (human-readable names resolved from
|
||||
configurationSettings.json when present) and flat Device Configuration /
|
||||
Compliance Policy objects.
|
||||
|
||||
Output columns: Policy, Setting, Value
|
||||
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
||||
Group names resolved from MigrationTable.json (created by IntuneManagement export).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
OUTPUT_FILE = "settings-report.csv"
|
||||
|
||||
BASE_FIELDNAMES = ["Policy", "Setting", "Value"]
|
||||
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
|
||||
|
||||
_SKIP_KEYS = {
|
||||
"@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version",
|
||||
"displayName", "description", "roleScopeTagIds", "scheduledActionsForRule",
|
||||
"assignments", "deviceStatusOverview", "userStatusOverview",
|
||||
"deviceStatuses", "userStatuses", "deviceManagementApplicabilityRuleOsEdition",
|
||||
"deviceManagementApplicabilityRuleOsVersion", "deviceManagementApplicabilityRuleDeviceMode",
|
||||
"supportsScopeTags", "settingCount", "priorityMetaData", "creationSource",
|
||||
"templateReference", "name", "platforms", "technologies",
|
||||
}
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--root", required=True,
|
||||
help="Path to backup root containing 'Settings Catalog', "
|
||||
"'Device Configurations', etc.")
|
||||
p.add_argument("--output", default=OUTPUT_FILE,
|
||||
help=f"Output CSV file path (default: {OUTPUT_FILE})")
|
||||
p.add_argument("--include-assignments", action="store_true",
|
||||
help="Append AssignmentState, IncludeTargets, ExcludeTargets columns. "
|
||||
"Group names resolved from MigrationTable.json when present.")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog lookup (Settings Catalog human-readable names)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_catalog(root: Path) -> dict[str, Any]:
|
||||
path = root / "configurationSettings.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
with path.open(encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
entries = raw.get("value", raw) if isinstance(raw, dict) else raw
|
||||
return {e["id"]: e for e in entries if "id" in e}
|
||||
|
||||
|
||||
def _setting_name(catalog: dict[str, Any], setting_id: str) -> str:
|
||||
defn = catalog.get(setting_id)
|
||||
if defn:
|
||||
return defn.get("displayName") or defn.get("name") or setting_id
|
||||
tail = setting_id.rsplit("_", 1)[-1]
|
||||
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", tail).title()
|
||||
|
||||
|
||||
def _choice_label(catalog: dict[str, Any], setting_id: str, value_id: str) -> str:
|
||||
defn = catalog.get(setting_id)
|
||||
if defn:
|
||||
for opt in defn.get("options", []):
|
||||
if opt.get("itemId") == value_id:
|
||||
return opt.get("displayName") or value_id
|
||||
suffix = value_id.removeprefix(setting_id).lstrip("_")
|
||||
if suffix == "1":
|
||||
return "Enabled"
|
||||
if suffix == "0":
|
||||
return "Disabled"
|
||||
return suffix.title() if suffix.islower() else suffix or value_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assignment resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_groups(root: Path) -> dict[str, str]:
|
||||
"""Return groupId → displayName from MigrationTable.json (created by IntuneManagement export)."""
|
||||
path = root / "MigrationTable.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
obj["Id"]: obj["DisplayName"]
|
||||
for obj in data.get("Objects", [])
|
||||
if obj.get("Type") == "Group" and obj.get("Id") and obj.get("DisplayName")
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_target(target: dict, groups: dict[str, str]) -> tuple[str, str]:
|
||||
"""Returns (intent, display_name)."""
|
||||
ttype = target.get("@odata.type", "")
|
||||
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
||||
return "include", "All devices"
|
||||
if ttype == "#microsoft.graph.allLicensedUsersAssignmentTarget":
|
||||
return "include", "All users"
|
||||
gid = target.get("groupId", "")
|
||||
name = (groups.get(gid)
|
||||
or target.get("groupDisplayName")
|
||||
or target.get("groupName")
|
||||
or gid
|
||||
or "Unresolved group")
|
||||
if ttype == "#microsoft.graph.exclusionGroupAssignmentTarget":
|
||||
return "exclude", name
|
||||
return "include", name
|
||||
|
||||
|
||||
def _summarize_assignments(policy: dict, groups: dict[str, str]) -> dict[str, str]:
|
||||
assignments = policy.get("assignments")
|
||||
if not isinstance(assignments, list):
|
||||
return {"AssignmentState": "NotExported", "IncludeTargets": "", "ExcludeTargets": ""}
|
||||
if not assignments:
|
||||
return {"AssignmentState": "Unassigned", "IncludeTargets": "", "ExcludeTargets": ""}
|
||||
|
||||
include: list[str] = []
|
||||
exclude: list[str] = []
|
||||
for item in assignments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
target = item.get("target") or {}
|
||||
intent, name = _resolve_target(target, groups)
|
||||
if str(item.get("intent", "")).lower() == "exclude" or intent == "exclude":
|
||||
exclude.append(name)
|
||||
else:
|
||||
include.append(name)
|
||||
|
||||
return {
|
||||
"AssignmentState": "Assigned",
|
||||
"IncludeTargets": "; ".join(sorted(set(include))),
|
||||
"ExcludeTargets": "; ".join(sorted(set(exclude))),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings Catalog recursive walker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
parent: str = "") -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
otype = si.get("@odata.type", "")
|
||||
sid = si.get("settingDefinitionId", "")
|
||||
name = _setting_name(catalog, sid)
|
||||
if parent:
|
||||
name = f"{parent} > {name}"
|
||||
|
||||
children: list[dict] = []
|
||||
|
||||
if "ChoiceSettingInstance" in otype and "Collection" not in otype:
|
||||
csv_val = si.get("choiceSettingValue", {})
|
||||
value = _choice_label(catalog, sid, csv_val.get("value", ""))
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": value})
|
||||
children = csv_val.get("children", [])
|
||||
|
||||
elif "SimpleSettingInstance" in otype and "Collection" not in otype:
|
||||
raw = si.get("simpleSettingValue", {})
|
||||
value = str(raw.get("value", "")) if isinstance(raw, dict) else str(raw)
|
||||
if value:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": value})
|
||||
|
||||
elif "SimpleSettingCollectionInstance" in otype:
|
||||
vals = [
|
||||
str(v.get("value", "")) if isinstance(v, dict) else str(v)
|
||||
for v in si.get("simpleSettingCollectionValue", [])
|
||||
]
|
||||
if vals:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": "; ".join(vals)})
|
||||
|
||||
elif "ChoiceSettingCollectionInstance" in otype:
|
||||
items = si.get("choiceSettingCollectionValue", [])
|
||||
vals = [_choice_label(catalog, sid, item.get("value", ""))
|
||||
for item in items if isinstance(item, dict)]
|
||||
if vals:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": "; ".join(vals)})
|
||||
|
||||
elif "GroupSettingCollectionInstance" in otype:
|
||||
for group in si.get("groupSettingCollectionValue", []):
|
||||
children.extend(group.get("children", []))
|
||||
|
||||
for child in children:
|
||||
if isinstance(child, dict):
|
||||
rows.extend(_walk(child, catalog, policy, parent=name))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Processors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
|
||||
for name in candidates:
|
||||
p = root / name
|
||||
if p.is_dir():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def process_settings_catalog(root: Path, catalog: dict[str, Any],
|
||||
groups: dict[str, str],
|
||||
include_assignments: bool) -> list[dict]:
|
||||
folder = _resolve_folder(root, "SettingsCatalog", "Settings Catalog")
|
||||
rows: list[dict] = []
|
||||
if folder is None:
|
||||
return rows
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy = json.load(f)
|
||||
policy_name = policy.get("name") or path.stem
|
||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||
for setting in policy.get("settings", []):
|
||||
si = setting.get("settingInstance", {})
|
||||
for row in _walk(si, catalog, policy_name):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def process_flat_category(root: Path, category: str,
|
||||
groups: dict[str, str],
|
||||
include_assignments: bool,
|
||||
*aliases: str) -> list[dict]:
|
||||
folder = _resolve_folder(root, category, *aliases)
|
||||
if folder is None:
|
||||
return []
|
||||
if (folder / "Policies").is_dir():
|
||||
folder = folder / "Policies"
|
||||
rows: list[dict] = []
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy = json.load(f)
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
policy_name = policy.get("displayName") or policy.get("name") or path.stem
|
||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||
for key, value in policy.items():
|
||||
if key in _SKIP_KEYS or value is None:
|
||||
continue
|
||||
if isinstance(value, (dict, list)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
if len(value_str) > 500:
|
||||
value_str = value_str[:497] + "..."
|
||||
else:
|
||||
value_str = str(value)
|
||||
row = {"Policy": policy_name, "Setting": key, "Value": value_str}
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = Path(args.root)
|
||||
out_path = Path(args.output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
include_assignments: bool = args.include_assignments
|
||||
fieldnames = BASE_FIELDNAMES + (ASSIGNMENT_FIELDNAMES if include_assignments else [])
|
||||
|
||||
catalog = _load_catalog(root)
|
||||
groups = _load_groups(root) if include_assignments else {}
|
||||
|
||||
rows: list[dict] = []
|
||||
rows.extend(process_settings_catalog(root, catalog, groups, include_assignments))
|
||||
rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments,
|
||||
"Device Configuration", "Device Configurations"))
|
||||
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
|
||||
"Compliance Policies"))
|
||||
rows.extend(process_flat_category(root, "CompliancePoliciesV2", groups, include_assignments,
|
||||
"Compliance Policies - V2"))
|
||||
rows.extend(process_flat_category(root, "EndpointSecurity", groups, include_assignments,
|
||||
"Endpoint Security"))
|
||||
rows.extend(process_flat_category(root, "AdministrativeTemplates", groups, include_assignments,
|
||||
"Administrative Templates"))
|
||||
|
||||
for row in rows:
|
||||
for col in fieldnames:
|
||||
v = row.get(col, "")
|
||||
if isinstance(v, str) and ("\n" in v or "\r" in v):
|
||||
row[col] = v.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
|
||||
|
||||
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
print(f"Written {len(rows)} rows → {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -33,6 +33,14 @@ Does not delete the app registration in Entra ID.
|
||||
.PARAMETER DeleteApp
|
||||
Remove the app registration from the Entra tenant and clean up local credentials.
|
||||
Requires the same Microsoft Graph permissions as initialization.
|
||||
|
||||
.PARAMETER RotateSecret
|
||||
Create a new client secret for the existing app registration, remove the old
|
||||
IntuneManagementSecret credential, and update local storage. Does not recreate
|
||||
the app registration or re-grant admin consent.
|
||||
|
||||
.PARAMETER SecretExpiryYears
|
||||
Lifetime of the created client secret in years (1-5). Default: 1.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -46,7 +54,12 @@ param(
|
||||
|
||||
[switch]$Delete,
|
||||
|
||||
[switch]$DeleteApp
|
||||
[switch]$DeleteApp,
|
||||
|
||||
[switch]$RotateSecret,
|
||||
|
||||
[ValidateRange(1,5)]
|
||||
[int]$SecretExpiryYears = 1
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -145,6 +158,70 @@ if ($Delete)
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Rotate secret (no app recreation)
|
||||
if ($RotateSecret)
|
||||
{
|
||||
$existingAppId = Get-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId"
|
||||
if (-not $existingAppId)
|
||||
{
|
||||
throw "No saved AppId found for tenant $TenantId. Run without -RotateSecret to set up first."
|
||||
}
|
||||
|
||||
$requiredModulesRotate = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Applications")
|
||||
foreach ($mod in $requiredModulesRotate)
|
||||
{
|
||||
if (-not (Get-Module $mod -ListAvailable))
|
||||
{
|
||||
throw "Module '$mod' is not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser"
|
||||
}
|
||||
}
|
||||
Import-Module Microsoft.Graph.Authentication -Force
|
||||
Import-Module Microsoft.Graph.Applications -Force
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
Connect-MgGraph -Scopes "Application.ReadWrite.All" -NoWelcome
|
||||
|
||||
$appObj = Get-MgApplication -Filter "appId eq '$existingAppId'" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $appObj)
|
||||
{
|
||||
throw "App registration $existingAppId not found in tenant $TenantId."
|
||||
}
|
||||
|
||||
# Remove existing IntuneManagementSecret credentials
|
||||
$oldCreds = $appObj.PasswordCredentials | Where-Object { $_.DisplayName -eq "IntuneManagementSecret" }
|
||||
foreach ($cred in $oldCreds)
|
||||
{
|
||||
Write-Host "Removing old secret (KeyId: $($cred.KeyId))..." -ForegroundColor Yellow
|
||||
Remove-MgApplicationPassword -ApplicationId $appObj.Id -KeyId $cred.KeyId
|
||||
}
|
||||
|
||||
# Create new secret
|
||||
Write-Host "Creating new client secret..." -ForegroundColor Cyan
|
||||
$newCred = @{
|
||||
displayName = "IntuneManagementSecret"
|
||||
endDateTime = (Get-Date).AddYears($SecretExpiryYears)
|
||||
}
|
||||
$newSecret = Add-MgApplicationPassword -ApplicationId $appObj.Id -PasswordCredential $newCred
|
||||
|
||||
# Store new secret
|
||||
if ($IsMacOS)
|
||||
{
|
||||
$null = security add-generic-password -a "IntuneManagement" -s "IntuneMgmt-$existingAppId" -w "$($newSecret.SecretText)" -U 2>$null
|
||||
Write-Host "New secret stored in macOS Keychain." -ForegroundColor Green
|
||||
}
|
||||
else
|
||||
{
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppSecret" -Value $newSecret.SecretText
|
||||
Write-Host "New secret stored in $SettingsFile." -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "Secret rotated. Expiry: $((Get-Date).AddYears($SecretExpiryYears).ToString('yyyy-MM-dd'))" -ForegroundColor Green
|
||||
Disconnect-MgGraph | Out-Null
|
||||
return
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Microsoft Graph modules
|
||||
$requiredModules = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Applications")
|
||||
foreach ($mod in $requiredModules)
|
||||
@@ -389,16 +466,17 @@ if ($sp)
|
||||
Write-Host "Creating client secret..." -ForegroundColor Cyan
|
||||
$passwordCred = @{
|
||||
displayName = "IntuneManagementSecret"
|
||||
endDateTime = (Get-Date).AddYears(1)
|
||||
endDateTime = (Get-Date).AddYears($SecretExpiryYears)
|
||||
}
|
||||
$secret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred
|
||||
#endregion
|
||||
|
||||
#region Save settings
|
||||
Write-Host "Saving settings to $SettingsFile ..." -ForegroundColor Cyan
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId" -Value $app.AppId
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppLogin" -Value $true
|
||||
Save-AuthSetting -Key "TenantId" -Value $TenantId
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppId" -Value $app.AppId
|
||||
Save-AuthSetting -SubPath $TenantId -Key "GraphAzureAppLogin" -Value $true
|
||||
Save-AuthSetting -Key "TenantId" -Value $TenantId
|
||||
Save-AuthSetting -SubPath "EndpointManager" -Key "EMAzureApp" -Value $app.AppId
|
||||
|
||||
if ($IsMacOS)
|
||||
{
|
||||
@@ -426,7 +504,7 @@ if ($IsMacOS)
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Secret : $($secret.SecretText)"
|
||||
Write-Host "Secret : <stored in $SettingsFile>"
|
||||
}
|
||||
Write-Host "=============================================================" -ForegroundColor Green
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploy an Intune or CIS M365 baseline to multiple tenants from a CSV manifest.
|
||||
.DESCRIPTION
|
||||
Reads a CSV file with one row per tenant, invokes Deploy-IntuneBaseline.ps1 or
|
||||
Deploy-CISM365Baseline.ps1 for each row, and aggregates all per-tenant reports
|
||||
into a single combined CSV summary.
|
||||
|
||||
CSV columns (Deploy-IntuneBaseline mode):
|
||||
TenantId, BaselinePath, AppId, Secret, Certificate, AuthMode, ConflictResolution, WhatIf
|
||||
|
||||
CSV columns (Deploy-CISM365Baseline mode):
|
||||
TenantId, BaselinePath, AppId, Secret, Certificate, AuthMode, Mode, Workloads, WhatIf
|
||||
|
||||
All columns except TenantId and BaselinePath are optional.
|
||||
|
||||
.PARAMETER CsvPath
|
||||
Path to the CSV manifest file.
|
||||
|
||||
.PARAMETER ScriptMode
|
||||
Which deployment script to invoke per tenant: 'Intune' or 'CIS'. Default: Intune.
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Directory for per-tenant reports and the combined summary. Default: same directory as CsvPath.
|
||||
|
||||
.PARAMETER WhatIf
|
||||
Propagates WhatIf to every tenant run, overriding the CSV column.
|
||||
|
||||
.EXAMPLE
|
||||
./Scripts/Invoke-BaselineBatch.ps1 -CsvPath ./tenants.csv -ScriptMode Intune
|
||||
.EXAMPLE
|
||||
./Scripts/Invoke-BaselineBatch.ps1 -CsvPath ./tenants.csv -ScriptMode CIS -WhatIf
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CsvPath,
|
||||
|
||||
[ValidateSet("Intune","CIS")]
|
||||
[string]$ScriptMode = "Intune",
|
||||
|
||||
[string]$OutputDir,
|
||||
|
||||
[switch]$WhatIf
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$csvResolved = Resolve-Path $CsvPath | Select-Object -ExpandProperty Path
|
||||
if (-not (Test-Path $csvResolved)) { throw "CSV not found: $CsvPath" }
|
||||
|
||||
$rows = Import-Csv -Path $csvResolved
|
||||
if (-not $rows -or $rows.Count -eq 0) { throw "CSV is empty: $CsvPath" }
|
||||
|
||||
$scriptDir = Split-Path -Parent $PSScriptRoot
|
||||
$intuneScript = Join-Path $scriptDir "Scripts/Deploy-IntuneBaseline.ps1"
|
||||
$cisScript = Join-Path $scriptDir "Scripts/Deploy-CISM365Baseline.ps1"
|
||||
|
||||
$targetScript = if ($ScriptMode -eq "CIS") { $cisScript } else { $intuneScript }
|
||||
if (-not (Test-Path $targetScript)) { throw "Deployment script not found: $targetScript" }
|
||||
|
||||
$resolvedOutputDir = if ($OutputDir) { $OutputDir } else { Split-Path -Parent $csvResolved }
|
||||
if (-not (Test-Path $resolvedOutputDir)) { New-Item -ItemType Directory -Path $resolvedOutputDir | Out-Null }
|
||||
|
||||
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
$batchSummary = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
$rowIndex = 0
|
||||
foreach ($row in $rows)
|
||||
{
|
||||
$rowIndex++
|
||||
$tenantId = $row.TenantId?.Trim()
|
||||
$baselinePath = $row.BaselinePath?.Trim()
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($tenantId) -or [string]::IsNullOrWhiteSpace($baselinePath))
|
||||
{
|
||||
Write-Warning "Row $rowIndex skipped: TenantId or BaselinePath is empty."
|
||||
$batchSummary.Add([PSCustomObject]@{
|
||||
Row = $rowIndex
|
||||
TenantId = $tenantId
|
||||
Baseline = $baselinePath
|
||||
Outcome = 'Skipped-InvalidRow'
|
||||
ReportPath = $null
|
||||
Error = 'TenantId or BaselinePath empty'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$tenantReportPath = Join-Path $resolvedOutputDir "${tenantId}_${ts}.csv"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "======================================================" -ForegroundColor Cyan
|
||||
Write-Host "Tenant $rowIndex/$($rows.Count): $tenantId" -ForegroundColor Cyan
|
||||
Write-Host "Baseline : $baselinePath" -ForegroundColor Cyan
|
||||
Write-Host "======================================================" -ForegroundColor Cyan
|
||||
|
||||
$params = @{
|
||||
TenantId = $tenantId
|
||||
BaselinePath = $baselinePath
|
||||
}
|
||||
|
||||
if ($row.PSObject.Properties['AppId'] -and $row.AppId) { $params.AppId = $row.AppId }
|
||||
if ($row.PSObject.Properties['Secret'] -and $row.Secret) { $params.Secret = $row.Secret }
|
||||
if ($row.PSObject.Properties['Certificate'] -and $row.Certificate) { $params.Certificate = $row.Certificate }
|
||||
if ($row.PSObject.Properties['AuthMode'] -and $row.AuthMode) { $params.AuthMode = $row.AuthMode }
|
||||
|
||||
if ($WhatIf -or ($row.PSObject.Properties['WhatIf'] -and $row.WhatIf -match '(?i)^true|yes|1$'))
|
||||
{
|
||||
$params.WhatIf = $true
|
||||
}
|
||||
|
||||
if ($ScriptMode -eq "Intune")
|
||||
{
|
||||
if ($row.PSObject.Properties['ConflictResolution'] -and $row.ConflictResolution) { $params.ConflictResolution = $row.ConflictResolution }
|
||||
$params.ReportPath = $tenantReportPath
|
||||
}
|
||||
else
|
||||
{
|
||||
if ($row.PSObject.Properties['Mode'] -and $row.Mode) { $params.Mode = $row.Mode }
|
||||
if ($row.PSObject.Properties['Workloads'] -and $row.Workloads)
|
||||
{
|
||||
$params.Workloads = $row.Workloads -split '\s*[,;]\s*'
|
||||
}
|
||||
}
|
||||
|
||||
$outcome = 'Success'
|
||||
$errorMsg = $null
|
||||
|
||||
try
|
||||
{
|
||||
& $targetScript @params
|
||||
}
|
||||
catch
|
||||
{
|
||||
$outcome = 'Failed'
|
||||
$errorMsg = $_.Exception.Message
|
||||
Write-Warning "Tenant $tenantId failed: $errorMsg"
|
||||
}
|
||||
|
||||
$batchSummary.Add([PSCustomObject]@{
|
||||
Row = $rowIndex
|
||||
TenantId = $tenantId
|
||||
Baseline = $baselinePath
|
||||
Outcome = $outcome
|
||||
ReportPath = if ($ScriptMode -eq "Intune" -and (Test-Path $tenantReportPath)) { $tenantReportPath } else { $null }
|
||||
Error = $errorMsg
|
||||
})
|
||||
}
|
||||
|
||||
$summaryPath = Join-Path $resolvedOutputDir "BatchSummary_${ts}.csv"
|
||||
$batchSummary | Export-Csv -Path $summaryPath -NoTypeInformation -Force
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "======================================================" -ForegroundColor Green
|
||||
Write-Host "Batch complete. $($rows.Count) tenant(s) processed." -ForegroundColor Green
|
||||
Write-Host "Summary: $summaryPath" -ForegroundColor Green
|
||||
|
||||
$failed = $batchSummary | Where-Object { $_.Outcome -ne 'Success' }
|
||||
if ($failed)
|
||||
{
|
||||
Write-Host "Failed tenants:" -ForegroundColor Red
|
||||
$failed | ForEach-Object { Write-Host " $($_.TenantId): $($_.Error)" -ForegroundColor Red }
|
||||
}
|
||||
Write-Host "======================================================" -ForegroundColor Green
|
||||
@@ -0,0 +1,682 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates a Conditional Access baseline YAML manifest from high-level security requirements.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates a CIS M365-compatible baseline YAML file covering Conditional Access policies.
|
||||
The output can be reviewed and then deployed with Deploy-CISM365Baseline.ps1.
|
||||
|
||||
Policy names follow the structured naming convention:
|
||||
<INDEX>-<TARGET>-<APP/RESOURCE>-<CONTROL>-<SCOPE>
|
||||
|
||||
Index ranges:
|
||||
CA0xx – User policies
|
||||
CA1xx – Guest policies
|
||||
CA2xx – Application policies
|
||||
CA3xx – Admin policies
|
||||
CA4xx – Threat policies
|
||||
|
||||
Example: CA001-AllUsers-AllApps-BlockLegacyAuth-Prod
|
||||
|
||||
.PARAMETER RequireTrustedLocations
|
||||
Enforce that users can only sign in from trusted named locations.
|
||||
- None: No location restriction policy
|
||||
- AllUsers: All users must be on trusted locations
|
||||
- Admins: Only administrative roles must be on trusted locations
|
||||
- All: Both AllUsers and Admins policies
|
||||
|
||||
.PARAMETER AdminDeviceCompliance
|
||||
Device requirements for administrative roles.
|
||||
- None: No device policy for admins
|
||||
- Required: Admins must use compliant or hybrid-joined devices
|
||||
- RequiredWithMFA: Admins must use compliant/hybrid-joined devices AND MFA
|
||||
|
||||
.PARAMETER GuestMFA
|
||||
Require MFA for guest and external users.
|
||||
|
||||
.PARAMETER SessionTimeoutHours
|
||||
Require re-authentication after N hours. 0 disables session timeout policies.
|
||||
|
||||
.PARAMETER DisablePersistentBrowser
|
||||
Prevent persistent browser sessions (users must re-auth when browser restarts).
|
||||
|
||||
.PARAMETER TrustedLocationsExemptFromReauth
|
||||
When SessionTimeoutHours is set, do not require re-authentication from trusted locations.
|
||||
This creates an exclusion so users on trusted networks are not nagged.
|
||||
|
||||
.PARAMETER RequireMFAForAllUsers
|
||||
Require MFA for all member users.
|
||||
|
||||
.PARAMETER BlockLegacyAuth
|
||||
Block all legacy authentication protocols (Exchange ActiveSync, basic auth, etc.).
|
||||
|
||||
.PARAMETER BlockHighRiskSignIns
|
||||
Block sign-ins with medium or high risk level (requires Entra ID P2).
|
||||
|
||||
.PARAMETER RequireMFAForAdminPortals
|
||||
Require MFA when accessing Microsoft admin portals (Azure, M365, Exchange, etc.).
|
||||
|
||||
.PARAMETER RequireMFAForAdmins
|
||||
Require MFA for all administrative roles across all applications.
|
||||
|
||||
.PARAMETER RequirePhishingResistantMFAForAdmins
|
||||
Require phishing-resistant MFA (FIDO2, certificate) for administrative roles.
|
||||
|
||||
.PARAMETER BlockDeviceCodeFlow
|
||||
Block sign-ins using the device code authentication flow.
|
||||
|
||||
.PARAMETER RequireManagedDeviceForAllUsers
|
||||
Require all users to use compliant or hybrid-joined devices.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Path where the generated YAML baseline will be written.
|
||||
|
||||
.PARAMETER Scope
|
||||
Deployment stage suffix applied to every policy name.
|
||||
- Test, Pilot1, Pilot2, Pilot3, Prod
|
||||
|
||||
.PARAMETER UseDescriptiveNames
|
||||
Use human-readable descriptive names instead of the structured naming convention.
|
||||
|
||||
.PARAMETER Prefix
|
||||
Optional prefix applied before the INDEX (e.g. "ACME-" produces ACME-CA001-...).
|
||||
|
||||
.PARAMETER BreakGlassGroup
|
||||
Name of the break-glass group to auto-exclude from every CA policy.
|
||||
|
||||
.PARAMETER ReportOnly
|
||||
Default all generated policies to report-only mode (recommended for initial rollout).
|
||||
|
||||
.EXAMPLE
|
||||
# Minimal baseline: MFA for all + block legacy auth
|
||||
./Scripts/New-ConditionalAccessBaseline.ps1 `
|
||||
-RequireMFAForAllUsers `
|
||||
-BlockLegacyAuth `
|
||||
-OutputPath ./Baselines/MyCA.yaml
|
||||
|
||||
.EXAMPLE
|
||||
# Full security baseline with structured names scoped to production
|
||||
./Scripts/New-ConditionalAccessBaseline.ps1 `
|
||||
-RequireTrustedLocations AllUsers `
|
||||
-AdminDeviceCompliance RequiredWithMFA `
|
||||
-GuestMFA `
|
||||
-SessionTimeoutHours 8 `
|
||||
-DisablePersistentBrowser `
|
||||
-TrustedLocationsExemptFromReauth `
|
||||
-BlockLegacyAuth `
|
||||
-BlockHighRiskSignIns `
|
||||
-OutputPath ./Baselines/SecureTenant-CA.yaml `
|
||||
-Scope Prod
|
||||
|
||||
.EXAMPLE
|
||||
# Pilot rollout with descriptive names instead of structured convention
|
||||
./Scripts/New-ConditionalAccessBaseline.ps1 `
|
||||
-RequireMFAForAllUsers `
|
||||
-BlockLegacyAuth `
|
||||
-OutputPath ./Baselines/Pilot-CA.yaml `
|
||||
-Scope Pilot1 `
|
||||
-UseDescriptiveNames
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[ValidateSet('None','AllUsers','Admins','All')]
|
||||
[string]$RequireTrustedLocations = 'None',
|
||||
|
||||
[Parameter()]
|
||||
[ValidateSet('None','Required','RequiredWithMFA')]
|
||||
[string]$AdminDeviceCompliance = 'None',
|
||||
|
||||
[Parameter()]
|
||||
[switch]$GuestMFA,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateRange(0,24)]
|
||||
[int]$SessionTimeoutHours = 0,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$DisablePersistentBrowser,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$TrustedLocationsExemptFromReauth,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$RequireMFAForAllUsers,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$BlockLegacyAuth,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$BlockHighRiskSignIns,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$RequireMFAForAdminPortals,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$RequireMFAForAdmins,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$RequirePhishingResistantMFAForAdmins,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$BlockDeviceCodeFlow,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$RequireManagedDeviceForAllUsers,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateSet('Test','Pilot1','Pilot2','Pilot3','Prod')]
|
||||
[string]$Scope = 'Prod',
|
||||
|
||||
[Parameter()]
|
||||
[switch]$UseDescriptiveNames,
|
||||
|
||||
[Parameter()]
|
||||
[string]$Prefix = '',
|
||||
|
||||
[Parameter()]
|
||||
[string]$BreakGlassGroup = 'CIS-BreakGlass',
|
||||
|
||||
[Parameter()]
|
||||
[switch]$ReportOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# =====================================================================
|
||||
# Naming convention engine
|
||||
# =====================================================================
|
||||
# Format: CA<area><scope><seq2digit>-<TARGET>-<APP/RESOURCE>-<CONTROL>
|
||||
# Area: 0=Threat/Tenant, 1=User, 2=Admin, 3=Guest, 4=Application
|
||||
# Scope: 0=Test, 1=Pilot1, 2=Pilot2, 3=Pilot3, 9=Prod
|
||||
# Seq: auto-increment per area
|
||||
# =====================================================================
|
||||
$script:AreaDigitMap = @{
|
||||
'User' = '1'
|
||||
'Guest' = '3'
|
||||
'Application' = '4'
|
||||
'Admin' = '2'
|
||||
'Threat' = '0'
|
||||
}
|
||||
$script:ScopeDigitMap = @{
|
||||
'Test' = '0'
|
||||
'Pilot1' = '1'
|
||||
'Pilot2' = '2'
|
||||
'Pilot3' = '3'
|
||||
'Prod' = '9'
|
||||
}
|
||||
$script:NextSeq = @{
|
||||
'0' = 1
|
||||
'1' = 1
|
||||
'2' = 1
|
||||
'3' = 1
|
||||
'4' = 1
|
||||
}
|
||||
|
||||
function Get-StructuredPolicyName {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('User','Guest','Application','Admin','Threat')]
|
||||
[string]$Category,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Target,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$AppResource,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Control
|
||||
)
|
||||
$area = $script:AreaDigitMap[$Category]
|
||||
$scope = $script:ScopeDigitMap[$Scope]
|
||||
$seq = $script:NextSeq[$area]++
|
||||
$idx = "$area$scope$($seq.ToString('D2'))"
|
||||
$name = "CA$idx-${Target}-${AppResource}-${Control}"
|
||||
if ($Prefix) { $name = "$Prefix$name" }
|
||||
return $name
|
||||
}
|
||||
|
||||
function Get-DescriptivePolicyName {
|
||||
param([string]$Name)
|
||||
if ($Prefix) { return "$Prefix$Name" }
|
||||
return $Name
|
||||
}
|
||||
|
||||
function Get-DefaultState {
|
||||
if ($ReportOnly) { return 'enabledForReportingButNotEnforced' }
|
||||
return 'enabled'
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# Shared data
|
||||
# =====================================================================
|
||||
$script:AdminRoles = @(
|
||||
'Global Administrator',
|
||||
'Privileged Role Administrator',
|
||||
'Security Administrator',
|
||||
'Exchange Administrator',
|
||||
'SharePoint Administrator',
|
||||
'Conditional Access Administrator',
|
||||
'Application Administrator',
|
||||
'Cloud Application Administrator',
|
||||
'User Administrator',
|
||||
'Helpdesk Administrator',
|
||||
'Billing Administrator',
|
||||
'Authentication Administrator',
|
||||
'Password Administrator'
|
||||
)
|
||||
|
||||
$script:AdminPortalAppIds = @(
|
||||
'797f4846-ba00-4fd7-ba43-dac1f8f63013', # Azure Management
|
||||
'c44b4083-3bb0-49c1-b47d-974e53cbdf3c', # Azure AD PowerShell
|
||||
'1b730954-1685-4b74-9bfd-dac224a7b894', # Microsoft Graph PowerShell
|
||||
'00000003-0000-0ff1-ce00-000000000000', # Office 365 Exchange Online
|
||||
'00000003-0000-0000-c000-000000000000', # Microsoft Graph
|
||||
'de8bc8b5-d9f9-48b1-a8ad-b748da725064', # Microsoft Intune
|
||||
'00000002-0000-0ff1-ce00-000000000000', # Office 365 SharePoint Online
|
||||
'66a88757-258c-4c72-893c-3e8bed4d6899' # Microsoft365DSC
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# Policy builders
|
||||
# =====================================================================
|
||||
|
||||
function New-PolicyBlockLegacyAuth {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-Legacy-Authentication' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockLegacyAuth }
|
||||
description = 'Block all legacy authentication protocols (EAS, basic auth, IMAP, POP, etc.)'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeUsers = @('All') }
|
||||
clientAppTypes = @('exchangeActiveSync', 'other')
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('block')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyRequireMFAAllUsers {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control RequireMFA }
|
||||
description = 'Require multi-factor authentication for all users'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeUsers = @('All') }
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('mfa')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyRequireMFAAdmins {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireMFA }
|
||||
description = 'Require multi-factor authentication for all administrative roles'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeRoles = $script:AdminRoles }
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('mfa')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyRequireMFAAdminPortals {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Admin-Portals' } else { Get-StructuredPolicyName -Category Application -Target AllUsers -AppResource AdminPortals -Control RequireMFA }
|
||||
description = 'Require MFA when accessing Microsoft admin portals'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = $script:AdminPortalAppIds }
|
||||
users = @{ includeUsers = @('All') }
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('mfa')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyTrustedLocations {
|
||||
param([switch]$ForAdmins)
|
||||
if ($ForAdmins) {
|
||||
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Trusted-Locations-Only-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control BlockUntrustedLocations }
|
||||
$desc = 'Administrators can only sign in from trusted named locations'
|
||||
$userDef = @{ includeRoles = $script:AdminRoles }
|
||||
} else {
|
||||
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Trusted-Locations-Only-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control BlockUntrustedLocations }
|
||||
$desc = 'All users can only sign in from trusted named locations'
|
||||
$userDef = @{ includeUsers = @('All') }
|
||||
}
|
||||
$policy = @{
|
||||
name = $name
|
||||
description = $desc
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = $userDef
|
||||
locations = @{
|
||||
includeLocations = @('All')
|
||||
excludeLocations = @('AllTrusted')
|
||||
}
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('block')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyAdminDeviceCompliance {
|
||||
param([switch]$WithMFA)
|
||||
$controls = @('compliantDevice', 'domainJoinedDevice')
|
||||
$operator = 'OR'
|
||||
$desc = 'Administrators must use compliant or hybrid-joined devices'
|
||||
|
||||
if ($WithMFA) {
|
||||
$controls = @('compliantDevice', 'domainJoinedDevice', 'mfa')
|
||||
$operator = 'AND'
|
||||
$desc = 'Administrators must use compliant/hybrid-joined devices AND MFA'
|
||||
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Compliant-Device-and-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireCompliantDeviceAndMFA }
|
||||
} else {
|
||||
$name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Compliant-Device-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequireCompliantDevice }
|
||||
}
|
||||
|
||||
$policy = @{
|
||||
name = $name
|
||||
description = $desc
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeRoles = $script:AdminRoles }
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = $controls
|
||||
operator = $operator
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyGuestMFA {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-MFA-Guests' } else { Get-StructuredPolicyName -Category Guest -Target Guests -AppResource AllApps -Control RequireMFA }
|
||||
description = 'Require multi-factor authentication for guest and external users'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{
|
||||
includeGuestsOrExternalUsers = @{
|
||||
guestTypes = @('internalGuest', 'b2bCollaborationGuest', 'b2bCollaborationMember', 'b2bDirectConnectUser')
|
||||
externalTenants = @{ membershipKind = 'all' }
|
||||
}
|
||||
}
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('mfa')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicySessionControls {
|
||||
param(
|
||||
[int]$TimeoutHours = 0,
|
||||
[switch]$DisablePersistent,
|
||||
[switch]$ExemptTrustedLocations
|
||||
)
|
||||
$sessionControls = @{}
|
||||
$parts = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
if ($TimeoutHours -gt 0) {
|
||||
$sessionControls['signInFrequency'] = @{
|
||||
value = $TimeoutHours
|
||||
type = 'hours'
|
||||
isEnabled = $true
|
||||
}
|
||||
$parts.Add("re-authenticate every $TimeoutHours hours")
|
||||
}
|
||||
|
||||
if ($DisablePersistent) {
|
||||
$sessionControls['persistentBrowser'] = @{
|
||||
mode = 'never'
|
||||
isEnabled = $true
|
||||
}
|
||||
$parts.Add('no persistent browser sessions')
|
||||
}
|
||||
|
||||
$desc = 'Session controls: ' + ($parts -join '; ')
|
||||
if ($ExemptTrustedLocations) {
|
||||
$desc += ' (exempt when on trusted locations)'
|
||||
}
|
||||
|
||||
$controlTag = if ($TimeoutHours -gt 0 -and $DisablePersistent) {
|
||||
'SessionControls'
|
||||
} elseif ($TimeoutHours -gt 0) {
|
||||
'SignInFrequency'
|
||||
} else {
|
||||
'NoPersistentBrowser'
|
||||
}
|
||||
|
||||
$name = if ($UseDescriptiveNames) {
|
||||
if ($TimeoutHours -gt 0 -and $DisablePersistent) {
|
||||
Get-DescriptivePolicyName 'Session-Timeout-and-No-Persistent-Browser'
|
||||
} elseif ($TimeoutHours -gt 0) {
|
||||
Get-DescriptivePolicyName "Session-Timeout-${TimeoutHours}h"
|
||||
} else {
|
||||
Get-DescriptivePolicyName 'No-Persistent-Browser'
|
||||
}
|
||||
} else {
|
||||
Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control $controlTag
|
||||
}
|
||||
|
||||
$conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeUsers = @('All') }
|
||||
}
|
||||
|
||||
if ($ExemptTrustedLocations) {
|
||||
$conditions['locations'] = @{
|
||||
excludeLocations = @('AllTrusted')
|
||||
}
|
||||
}
|
||||
|
||||
$policy = @{
|
||||
name = $name
|
||||
description = $desc
|
||||
state = Get-DefaultState
|
||||
conditions = $conditions
|
||||
grantControls = @{
|
||||
builtInControls = @('mfa')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
|
||||
if ($sessionControls.Count -gt 0) {
|
||||
$policy['sessionControls'] = $sessionControls
|
||||
}
|
||||
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyBlockHighRisk {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-High-Risk-SignIns' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockHighRisk }
|
||||
description = 'Block sign-ins with medium or high risk score (requires Entra ID P2)'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeUsers = @('All') }
|
||||
signInRiskLevels = @('medium', 'high')
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('block')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyPhishingResistantMFAAdmins {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-PhishingResistant-MFA-Admins' } else { Get-StructuredPolicyName -Category Admin -Target Admins -AppResource AllApps -Control RequirePhishingResistantMFA }
|
||||
description = 'Require phishing-resistant MFA (FIDO2, certificate) for administrative roles'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeRoles = $script:AdminRoles }
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('authenticationStrength')
|
||||
authenticationStrength = @{ id = '00000000-0000-0000-0000-000000000004' }
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyBlockDeviceCodeFlow {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Block-Device-Code-Flow' } else { Get-StructuredPolicyName -Category Threat -Target AllUsers -AppResource AllApps -Control BlockDeviceCodeFlow }
|
||||
description = 'Block sign-ins using the device code authentication flow'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeUsers = @('All') }
|
||||
authenticationFlows = @{
|
||||
deviceCodeFlow = @{ isEnabled = $true }
|
||||
}
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('block')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
function New-PolicyRequireManagedDeviceAllUsers {
|
||||
$policy = @{
|
||||
name = if ($UseDescriptiveNames) { Get-DescriptivePolicyName 'Require-Managed-Device-All-Users' } else { Get-StructuredPolicyName -Category User -Target AllUsers -AppResource AllApps -Control RequireCompliantDevice }
|
||||
description = 'Require all users to use compliant or hybrid-joined devices'
|
||||
state = Get-DefaultState
|
||||
conditions = @{
|
||||
applications = @{ includeApplications = @('All') }
|
||||
users = @{ includeUsers = @('All') }
|
||||
}
|
||||
grantControls = @{
|
||||
builtInControls = @('compliantDevice', 'domainJoinedDevice')
|
||||
operator = 'OR'
|
||||
}
|
||||
}
|
||||
return $policy
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# Build the policy list based on parameters
|
||||
# =====================================================================
|
||||
$policies = [System.Collections.Generic.List[hashtable]]::new()
|
||||
|
||||
if ($BlockLegacyAuth) { $policies.Add((New-PolicyBlockLegacyAuth)) }
|
||||
if ($RequireMFAForAllUsers) { $policies.Add((New-PolicyRequireMFAAllUsers)) }
|
||||
if ($RequireMFAForAdmins) { $policies.Add((New-PolicyRequireMFAAdmins)) }
|
||||
if ($RequireMFAForAdminPortals) { $policies.Add((New-PolicyRequireMFAAdminPortals)) }
|
||||
if ($BlockHighRiskSignIns) { $policies.Add((New-PolicyBlockHighRisk)) }
|
||||
if ($BlockDeviceCodeFlow) { $policies.Add((New-PolicyBlockDeviceCodeFlow)) }
|
||||
if ($RequirePhishingResistantMFAForAdmins) { $policies.Add((New-PolicyPhishingResistantMFAAdmins)) }
|
||||
if ($RequireManagedDeviceForAllUsers) { $policies.Add((New-PolicyRequireManagedDeviceAllUsers)) }
|
||||
|
||||
switch ($RequireTrustedLocations) {
|
||||
'AllUsers' { $policies.Add((New-PolicyTrustedLocations)) }
|
||||
'Admins' { $policies.Add((New-PolicyTrustedLocations -ForAdmins)) }
|
||||
'All' { $policies.Add((New-PolicyTrustedLocations)); $policies.Add((New-PolicyTrustedLocations -ForAdmins)) }
|
||||
}
|
||||
|
||||
switch ($AdminDeviceCompliance) {
|
||||
'Required' { $policies.Add((New-PolicyAdminDeviceCompliance)) }
|
||||
'RequiredWithMFA' { $policies.Add((New-PolicyAdminDeviceCompliance -WithMFA)) }
|
||||
}
|
||||
|
||||
if ($GuestMFA) { $policies.Add((New-PolicyGuestMFA)) }
|
||||
|
||||
if ($SessionTimeoutHours -gt 0 -or $DisablePersistentBrowser) {
|
||||
$policies.Add((New-PolicySessionControls `
|
||||
-TimeoutHours $SessionTimeoutHours `
|
||||
-DisablePersistent:$DisablePersistentBrowser `
|
||||
-ExemptTrustedLocations:$TrustedLocationsExemptFromReauth))
|
||||
}
|
||||
|
||||
if ($policies.Count -eq 0) {
|
||||
throw "No policies requested. Specify at least one requirement parameter (e.g. -RequireMFAForAllUsers, -BlockLegacyAuth, etc.)."
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# Serialize to YAML (requires powershell-yaml)
|
||||
# =====================================================================
|
||||
|
||||
function Test-YamlModule {
|
||||
return [bool](Get-Module -ListAvailable -Name powershell-yaml)
|
||||
}
|
||||
|
||||
if (-not (Test-YamlModule)) {
|
||||
Write-Host "powershell-yaml module is required but not installed." -ForegroundColor Yellow
|
||||
$confirm = Read-Host "Install powershell-yaml from PSGallery now? [Y/n]"
|
||||
if ($confirm -match "^\s*n") {
|
||||
throw "powershell-yaml is required. Install it with: Install-Module powershell-yaml -Scope CurrentUser -Force"
|
||||
}
|
||||
Install-Module powershell-yaml -Scope CurrentUser -Force
|
||||
}
|
||||
Import-Module powershell-yaml -Force
|
||||
|
||||
# Build the root document
|
||||
$yamlRoot = [ordered]@{
|
||||
baseline = [ordered]@{
|
||||
name = 'Generated-ConditionalAccess-Baseline'
|
||||
conflictResolution = 'Skip'
|
||||
whatIf = $false
|
||||
tenantConfig = [ordered]@{
|
||||
conditionalAccess = [ordered]@{
|
||||
reportOnly = $true
|
||||
breakGlassGroup = $BreakGlassGroup
|
||||
policies = $policies
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$yamlText = ConvertTo-Yaml -Data $yamlRoot
|
||||
|
||||
# Ensure output directory exists
|
||||
$outDir = Split-Path -Parent $OutputPath
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$yamlText | Set-Content -Path $OutputPath -Encoding UTF8 -Force
|
||||
|
||||
Write-Host "Generated Conditional Access baseline with $($policies.Count) policies." -ForegroundColor Green
|
||||
Write-Host "Output written to: $(Resolve-Path $OutputPath)" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Review the file, then deploy with:" -ForegroundColor Cyan
|
||||
Write-Host " ./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath '$OutputPath' -Mode Assess" -ForegroundColor Yellow
|
||||
Write-Host " ./Scripts/Deploy-CISM365Baseline.ps1 -BaselinePath '$OutputPath' -Mode Deploy -Apply" -ForegroundColor Yellow
|
||||
+155
-4
@@ -113,7 +113,7 @@ function Read-YesNo
|
||||
$defaultChar = if($Default) { "Y" } else { "N" }
|
||||
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
|
||||
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
|
||||
return $response -match "^\s*y"
|
||||
return $response -like 'y*'
|
||||
}
|
||||
|
||||
function Get-DefaultSettingsPath
|
||||
@@ -129,7 +129,7 @@ function Get-DefaultSettingsPath
|
||||
#endregion
|
||||
|
||||
#region Load defaults
|
||||
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
|
||||
$modulePath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Headless/IntuneManagement.Headless.psd1"
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
$defaultTypes = Get-DefaultIntunePolicyObjectTypes
|
||||
@@ -157,9 +157,160 @@ while($true)
|
||||
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
|
||||
|
||||
# 1. Action
|
||||
$action = Select-MenuItem -Items @("Export","Import") -Header "Select action"
|
||||
$action = Select-MenuItem -Items @("Export","Import","DeployCISBaseline","GenerateReports") -Header "Select action"
|
||||
if(-not $action) { continue }
|
||||
|
||||
# CIS M365 Baseline deployment flow
|
||||
if($action -eq "DeployCISBaseline")
|
||||
{
|
||||
# 2a. TenantId
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantId = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
|
||||
# 2b. Baseline path
|
||||
$defaultBaseline = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Baselines/CISM365-v7-Generated.yaml"
|
||||
$baselinePath = Read-Host "Baseline YAML path (default: $defaultBaseline)"
|
||||
if([string]::IsNullOrWhiteSpace($baselinePath)) { $baselinePath = $defaultBaseline }
|
||||
if(-not (Test-Path $baselinePath)) { Write-Host "Baseline file not found: $baselinePath" -ForegroundColor Red; continue }
|
||||
|
||||
# 2c. Mode
|
||||
$mode = Select-MenuItem -Items @("Assess","Deploy") -Header "Select mode"
|
||||
if(-not $mode) { continue }
|
||||
|
||||
# 2d. Apply (only for Deploy)
|
||||
$apply = $false
|
||||
if($mode -eq "Deploy")
|
||||
{
|
||||
$apply = Read-YesNo -Prompt "Apply changes? (No = dry-run report)" -Default $false
|
||||
}
|
||||
|
||||
# 2e. Workloads
|
||||
$allWorkloads = @("EntraID","ConditionalAccess","Exchange","SharePoint","Teams","PowerBI","Defender","Purview")
|
||||
Write-Host "`nWorkload selection..." -ForegroundColor Cyan
|
||||
$workloadSelection = Select-MenuItem -Items $allWorkloads -Header "Select workloads (Space to multi-select, or choose 'all')" -Multi
|
||||
if(-not $workloadSelection) { $workloadSelection = $allWorkloads }
|
||||
|
||||
# 2f. Auth mode
|
||||
$authMode = Select-MenuItem -Items @("AppOnly","Browser","DeviceCode") -Header "Select authentication mode"
|
||||
if(-not $authMode) { $authMode = "Browser" }
|
||||
|
||||
# 2g. Review
|
||||
Clear-Host
|
||||
Write-Host "Review your CIS M365 Baseline deployment:" -ForegroundColor Green
|
||||
Write-Host " TenantId : $tenantId"
|
||||
Write-Host " Baseline : $baselinePath"
|
||||
Write-Host " Mode : $mode"
|
||||
if($mode -eq "Deploy") { Write-Host " Apply : $apply" }
|
||||
Write-Host " Workloads : $($workloadSelection -join ', ')"
|
||||
Write-Host " Auth Mode : $authMode"
|
||||
|
||||
$confirm = Read-Host "`nProceed? [Y/n] (or type 'back' to restart)"
|
||||
if($confirm -eq "back") { continue }
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
|
||||
$result = [PSCustomObject]@{
|
||||
Action = $action
|
||||
TenantId = $tenantId
|
||||
BaselinePath = $baselinePath
|
||||
Mode = $mode
|
||||
Apply = $apply
|
||||
Workloads = $workloadSelection
|
||||
AuthMode = $authMode
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
# Generate Reports flow
|
||||
if($action -eq "GenerateReports")
|
||||
{
|
||||
$reportTypes = @("Settings","Assignments","ObjectInventory","All")
|
||||
$reportType = Select-MenuItem -Items $reportTypes -Header "Select report type"
|
||||
if(-not $reportType) { continue }
|
||||
|
||||
$dataSource = Select-MenuItem -Items @("Use existing backup","Pull fresh data from tenant") -Header "Data source"
|
||||
if(-not $dataSource) { continue }
|
||||
|
||||
$backupRoot = $null
|
||||
$tenantIdForReport = $null
|
||||
$exportPath = $null
|
||||
|
||||
if($dataSource -like "*fresh*")
|
||||
{
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantIdForReport = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { $tenantIdForReport = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
|
||||
$exportPath = Read-Host "Export path (where to save fresh data)"
|
||||
if([string]::IsNullOrWhiteSpace($exportPath)) { Write-Host "Export path is required." -ForegroundColor Red; continue }
|
||||
$backupRoot = $exportPath
|
||||
}
|
||||
else
|
||||
{
|
||||
$backupRoot = Read-Host "Backup root path (folder containing 'Settings Catalog', etc.)"
|
||||
if([string]::IsNullOrWhiteSpace($backupRoot)) { Write-Host "Backup root is required." -ForegroundColor Red; continue }
|
||||
if(-not (Test-Path $backupRoot)) { Write-Host "Path not found: $backupRoot" -ForegroundColor Red; continue }
|
||||
}
|
||||
|
||||
$outputDir = Read-Host "Enter output directory for reports"
|
||||
if([string]::IsNullOrWhiteSpace($outputDir)) { Write-Host "Output directory is required." -ForegroundColor Red; continue }
|
||||
|
||||
$includeAssignmentsInSettings = $false
|
||||
if($reportType -in @("Settings","All"))
|
||||
{
|
||||
$includeAssignmentsInSettings = Read-YesNo -Prompt "Include assignment columns in settings report?" -Default $false
|
||||
}
|
||||
|
||||
Clear-Host
|
||||
Write-Host "Review report generation:" -ForegroundColor Green
|
||||
Write-Host " Report Type : $reportType"
|
||||
Write-Host " Data Source : $dataSource"
|
||||
if($dataSource -like "*fresh*")
|
||||
{
|
||||
Write-Host " Tenant ID : $tenantIdForReport"
|
||||
Write-Host " Export Path : $exportPath"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host " Backup Root : $backupRoot"
|
||||
}
|
||||
Write-Host " Output Dir : $outputDir"
|
||||
if($reportType -in @("Settings","All"))
|
||||
{
|
||||
Write-Host " Include Assignments : $includeAssignmentsInSettings"
|
||||
}
|
||||
|
||||
$confirm = Read-Host "`nProceed? [Y/n] (or type 'back' to restart)"
|
||||
if($confirm -eq "back") { continue }
|
||||
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -like 'y*'))
|
||||
{
|
||||
Write-Host "Cancelled." -ForegroundColor Yellow; continue
|
||||
}
|
||||
|
||||
$result = [PSCustomObject]@{
|
||||
Action = "GenerateReports"
|
||||
DataSource = $dataSource
|
||||
ReportType = $reportType
|
||||
BackupRoot = $backupRoot
|
||||
OutputDir = $outputDir
|
||||
IncludeAssignmentsInSettings = $includeAssignmentsInSettings
|
||||
}
|
||||
if($dataSource -like "*fresh*")
|
||||
{
|
||||
$result | Add-Member -NotePropertyName TenantId -NotePropertyValue $tenantIdForReport
|
||||
$result | Add-Member -NotePropertyName ExportPath -NotePropertyValue $exportPath
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
# 2. TenantId
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
@@ -182,7 +333,7 @@ $nameFilter = Read-Host "Name filter regex (optional, e.g. '^Win-OIB-')"
|
||||
|
||||
# 6. Name Mutation
|
||||
$nameSearchPattern = Read-Host "Name search regex for mutation (optional, e.g. '^Win-OIB-')"
|
||||
$nameReplacePattern =
|
||||
$nameReplacePattern = $null
|
||||
if(-not [string]::IsNullOrWhiteSpace($nameSearchPattern))
|
||||
{
|
||||
$nameReplacePattern = Read-Host "Replacement string (e.g. 'Win-TEST-')"
|
||||
@@ -0,0 +1,99 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Launches the interactive Conditional Access Policy Wizard (TUI).
|
||||
|
||||
.DESCRIPTION
|
||||
Starts the Python-based TUI wizard that guides you through tenant,
|
||||
user, admin, guest, and application policy choices. The wizard
|
||||
generates a deployment-ready YAML baseline using the structured
|
||||
naming convention.
|
||||
|
||||
Automatically locates the project venv or system Python with the
|
||||
required packages (rich, pyyaml).
|
||||
|
||||
.EXAMPLE
|
||||
./Scripts/Start-CAWizard.ps1
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$wizardPath = Join-Path $PSScriptRoot 'ca-wizard.py'
|
||||
if (-not (Test-Path $wizardPath)) {
|
||||
throw "Wizard script not found: $wizardPath"
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# Resolve Python interpreter
|
||||
# =====================================================================
|
||||
|
||||
function Test-PythonPackages {
|
||||
param([string]$PyExe)
|
||||
if (-not $PyExe) { return $false }
|
||||
try {
|
||||
$result = & $PyExe -c "import rich, yaml" 2>&1
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
# 1. Project venv (Linux/macOS)
|
||||
$venvPy = Join-Path (Split-Path $PSScriptRoot -Parent) '.venv-pdf/bin/python3'
|
||||
if (Test-Path $venvPy) { $candidates.Add($venvPy) }
|
||||
|
||||
# 2. Project venv (Windows)
|
||||
$venvPyWin = Join-Path (Split-Path $PSScriptRoot -Parent) '.venv-pdf/Scripts/python.exe'
|
||||
if (Test-Path $venvPyWin) { $candidates.Add($venvPyWin) }
|
||||
|
||||
# 3. Common system commands
|
||||
foreach ($cmd in @('python3', 'python')) {
|
||||
$found = Get-Command $cmd -ErrorAction SilentlyContinue
|
||||
if ($found) { $candidates.Add($found.Source) }
|
||||
}
|
||||
|
||||
$pythonPath = $null
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-PythonPackages -PyExe $c) {
|
||||
$pythonPath = $c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# If nothing has the packages, try installing into the venv
|
||||
if (-not $pythonPath) {
|
||||
$venvPy = $candidates | Where-Object { $_ -match '\.venv' } | Select-Object -First 1
|
||||
if ($venvPy -and (Test-Path $venvPy)) {
|
||||
Write-Host "Installing required packages into venv..." -ForegroundColor Yellow
|
||||
$pip = Join-Path (Split-Path $venvPy -Parent) 'pip'
|
||||
if (-not (Test-Path $pip)) { $pip = Join-Path (Split-Path $venvPy -Parent) 'pip3' }
|
||||
& $pip install rich pyyaml 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
if (Test-PythonPackages -PyExe $venvPy) {
|
||||
$pythonPath = $venvPy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $pythonPath) {
|
||||
throw @"
|
||||
Could not find a Python interpreter with 'rich' and 'pyyaml' installed.
|
||||
|
||||
Please install the requirements:
|
||||
python3 -m pip install rich pyyaml
|
||||
|
||||
Or activate the project venv manually:
|
||||
source .venv-pdf/bin/activate
|
||||
python3 Scripts/ca-wizard.py
|
||||
"@
|
||||
}
|
||||
|
||||
Write-Host "Using Python: $pythonPath" -ForegroundColor DarkGray
|
||||
|
||||
# =====================================================================
|
||||
# Run wizard
|
||||
# =====================================================================
|
||||
& $pythonPath $wizardPath
|
||||
@@ -0,0 +1,263 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Export","Import","DeployCISBaseline","GenerateReports")]
|
||||
[string]$Action,
|
||||
|
||||
[string]$BaselinePath,
|
||||
|
||||
[ValidateSet("Assess","Deploy")]
|
||||
[string]$Mode = "Assess",
|
||||
|
||||
[string[]]$Workloads,
|
||||
|
||||
[switch]$Apply,
|
||||
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[string]$BatchFile,
|
||||
|
||||
[string]$NameFilter = "",
|
||||
|
||||
[string]$NameSearchPattern = "",
|
||||
|
||||
[string]$NameReplacePattern = "",
|
||||
|
||||
[string[]]$ObjectTypes,
|
||||
|
||||
[string]$ExportPath,
|
||||
|
||||
[string]$ImportPath,
|
||||
|
||||
[ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")]
|
||||
[string]$ImportType = "alwaysImport",
|
||||
|
||||
[switch]$IncludeAssignments,
|
||||
|
||||
[switch]$AddCompanyName,
|
||||
|
||||
[switch]$IncludeScopeTags,
|
||||
|
||||
[switch]$ReplaceDependencyIds,
|
||||
|
||||
[switch]$Interactive,
|
||||
|
||||
# GenerateReports params
|
||||
[ValidateSet("Settings","Assignments","ObjectInventory","All")]
|
||||
[string]$ReportType = "All",
|
||||
|
||||
[string]$BackupRoot,
|
||||
|
||||
[string]$OutputDir,
|
||||
|
||||
[string]$DataSource,
|
||||
|
||||
[switch]$IncludeAssignmentsInSettings
|
||||
)
|
||||
|
||||
$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1"
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
if($Interactive -and -not $Action)
|
||||
{
|
||||
Write-Host "Interactive mode will prompt for the action and other settings." -ForegroundColor Cyan
|
||||
}
|
||||
elseif(-not $Action)
|
||||
{
|
||||
throw "Action is required. Use -Interactive to select it in a terminal UI."
|
||||
}
|
||||
|
||||
if($Interactive)
|
||||
{
|
||||
$tuiScript = Join-Path (Split-Path -Parent $PSScriptRoot) "Scripts/Private/Start-IntuneManagementTui.ps1"
|
||||
if(Test-Path $tuiScript)
|
||||
{
|
||||
$tuiResult = & $tuiScript
|
||||
if(-not $tuiResult) { Write-Host "No selection made. Exiting." -ForegroundColor Yellow; exit 0 }
|
||||
foreach($prop in $tuiResult.PSObject.Properties)
|
||||
{
|
||||
if($null -ne $prop.Value -and $prop.Name -ne "Action")
|
||||
{
|
||||
Set-Variable -Name $prop.Name -Value $prop.Value
|
||||
}
|
||||
elseif($prop.Name -eq "Action")
|
||||
{
|
||||
$Action = $prop.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw "TUI script not found: $tuiScript"
|
||||
}
|
||||
}
|
||||
|
||||
if($Action -eq "GenerateReports")
|
||||
{
|
||||
if([string]::IsNullOrWhiteSpace($OutputDir)) { throw "OutputDir is required for GenerateReports." }
|
||||
|
||||
if($DataSource -like "*fresh*")
|
||||
{
|
||||
if([string]::IsNullOrWhiteSpace($TenantId)) { throw "TenantId is required when pulling fresh data." }
|
||||
$freshDest = if(-not [string]::IsNullOrWhiteSpace($ExportPath)) { $ExportPath } else { $BackupRoot }
|
||||
if([string]::IsNullOrWhiteSpace($freshDest)) { throw "ExportPath or BackupRoot required for fresh data pull." }
|
||||
|
||||
Write-Host "Pulling fresh data from tenant $TenantId ..." -ForegroundColor Cyan
|
||||
$freshParams = @{ Action = "Export"; TenantId = $TenantId; ExportPath = $freshDest; IncludeAssignments = $true; AuthMode = $AuthMode }
|
||||
if($AppId) { $freshParams.AppId = $AppId }
|
||||
if($Secret) { $freshParams.Secret = $Secret }
|
||||
elseif($Certificate) { $freshParams.Certificate = $Certificate }
|
||||
if($SettingsFile) { $freshParams.SettingsFile = $SettingsFile }
|
||||
Invoke-IntunePolicyAction @freshParams
|
||||
$BackupRoot = $freshDest
|
||||
}
|
||||
|
||||
# Validate inputs
|
||||
if([string]::IsNullOrWhiteSpace($BackupRoot)) { throw "BackupRoot is required for GenerateReports." }
|
||||
if(-not (Test-Path $BackupRoot)) { throw "BackupRoot not found: $BackupRoot" }
|
||||
|
||||
$python = Get-Command python3 -ErrorAction SilentlyContinue
|
||||
if(-not $python) { $python = Get-Command python -ErrorAction SilentlyContinue }
|
||||
if(-not $python) { throw "python3 not found. Install Python 3 to use GenerateReports." }
|
||||
$pythonExe = $python.Source
|
||||
|
||||
$scriptsDir = Split-Path -Parent $PSScriptRoot
|
||||
if(-not (Test-Path (Join-Path $scriptsDir "Scripts/Export-SettingsReport.py")))
|
||||
{
|
||||
$scriptsDir = $PSScriptRoot
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
function Invoke-Report
|
||||
{
|
||||
param([string]$Script, [string[]]$ScriptArgs)
|
||||
$fullScript = Join-Path $scriptsDir "Scripts/$Script"
|
||||
if(-not (Test-Path $fullScript)) { Write-Warning "Report script not found: $fullScript"; return }
|
||||
Write-Host "Running $Script ..." -ForegroundColor Cyan
|
||||
& $pythonExe $fullScript @ScriptArgs
|
||||
}
|
||||
|
||||
if($ReportType -in @("Settings","All"))
|
||||
{
|
||||
$settingsArgs = @("--root", $BackupRoot, "--output", (Join-Path $OutputDir "settings-report.csv"))
|
||||
if($IncludeAssignmentsInSettings) { $settingsArgs += "--include-assignments" }
|
||||
Invoke-Report -Script "Export-SettingsReport.py" -ScriptArgs $settingsArgs
|
||||
}
|
||||
|
||||
if($ReportType -in @("Assignments","All"))
|
||||
{
|
||||
Invoke-Report -Script "Export-AssignmentReport.py" -ScriptArgs @(
|
||||
"--root", $BackupRoot,
|
||||
"--output", (Join-Path $OutputDir "assignment-report.csv")
|
||||
)
|
||||
}
|
||||
|
||||
if($ReportType -in @("ObjectInventory","All"))
|
||||
{
|
||||
Invoke-Report -Script "Export-ObjectInventoryReport.py" -ScriptArgs @(
|
||||
"--root", $BackupRoot,
|
||||
"--output", (Join-Path $OutputDir "object-inventory.csv")
|
||||
)
|
||||
}
|
||||
|
||||
Write-Host "`nReports written to: $OutputDir" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
|
||||
if($Action -eq "DeployCISBaseline")
|
||||
{
|
||||
$deployScript = Join-Path (Split-Path -Parent $PSScriptRoot) "Scripts/Deploy-CISM365Baseline.ps1"
|
||||
if(-not (Test-Path $deployScript))
|
||||
{
|
||||
throw "CIS baseline deployment script not found: $deployScript"
|
||||
}
|
||||
|
||||
$deployParams = @{
|
||||
BaselinePath = $BaselinePath
|
||||
TenantId = $TenantId
|
||||
Mode = $Mode
|
||||
AuthMode = $AuthMode
|
||||
}
|
||||
|
||||
if($Apply) { $deployParams.Apply = $true }
|
||||
|
||||
if($PSBoundParameters.ContainsKey("Workloads") -or $Workloads)
|
||||
{
|
||||
$deployParams.Workloads = $Workloads
|
||||
}
|
||||
|
||||
if($Secret)
|
||||
{
|
||||
$deployParams.Secret = $Secret
|
||||
}
|
||||
elseif($Certificate)
|
||||
{
|
||||
$deployParams.Certificate = $Certificate
|
||||
}
|
||||
|
||||
if($AppId) { $deployParams.AppId = $AppId }
|
||||
if($RedirectUri) { $deployParams.RedirectUri = $RedirectUri }
|
||||
|
||||
& $deployScript @deployParams
|
||||
return
|
||||
}
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($TenantId))
|
||||
{
|
||||
throw "TenantId is required for Action '$Action'."
|
||||
}
|
||||
|
||||
$invokeParams = @{
|
||||
Action = $Action
|
||||
TenantId = $TenantId
|
||||
AppId = $AppId
|
||||
AuthMode = $AuthMode
|
||||
SettingsFile = $SettingsFile
|
||||
BatchFile = $BatchFile
|
||||
NameFilter = $NameFilter
|
||||
NameSearchPattern = $NameSearchPattern
|
||||
NameReplacePattern = $NameReplacePattern
|
||||
ExportPath = $ExportPath
|
||||
ImportPath = $ImportPath
|
||||
ImportType = $ImportType
|
||||
IncludeAssignments = $IncludeAssignments
|
||||
AddCompanyName = $AddCompanyName
|
||||
IncludeScopeTags = $IncludeScopeTags
|
||||
ReplaceDependencyIds = $ReplaceDependencyIds
|
||||
}
|
||||
|
||||
if($Interactive -and $Action) { $invokeParams.Action = $Action }
|
||||
|
||||
if($PSBoundParameters.ContainsKey("ObjectTypes") -or $ObjectTypes)
|
||||
{
|
||||
$invokeParams.ObjectTypes = $ObjectTypes
|
||||
}
|
||||
|
||||
if($Secret)
|
||||
{
|
||||
$invokeParams.Secret = $Secret
|
||||
}
|
||||
elseif($Certificate)
|
||||
{
|
||||
$invokeParams.Certificate = $Certificate
|
||||
}
|
||||
|
||||
if($RedirectUri)
|
||||
{
|
||||
$invokeParams.RedirectUri = $RedirectUri
|
||||
}
|
||||
|
||||
Invoke-IntunePolicyAction @invokeParams
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert CIS M365 v7.0.0 draft PDF to YAML baseline manifest.
|
||||
Called by ConvertFrom-CISPDF.ps1
|
||||
"""
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from pypdf import PdfReader
|
||||
|
||||
|
||||
def parse_profiles(pa_text: str | None) -> set[tuple[str, str]]:
|
||||
"""Extract (level, license) tuples from Profile Applicability text.
|
||||
Example: '• E3 Level 1 • E5 Level 2' → {('L1','E3'), ('L2','E5')}
|
||||
"""
|
||||
if not pa_text:
|
||||
return set()
|
||||
profiles = set()
|
||||
# Split by bullet to avoid cross-bullet matching
|
||||
bullets = re.split(r'\s*•\s*', pa_text)
|
||||
for bullet in bullets:
|
||||
bullet = bullet.strip()
|
||||
if not bullet:
|
||||
continue
|
||||
# Look for patterns like "E3 Level 1" or "Level 1 E3" within a single bullet
|
||||
m = re.search(r'\b(E3|E5)\b.*\bLevel\s+(1|2)\b', bullet, re.IGNORECASE)
|
||||
if not m:
|
||||
m = re.search(r'\bLevel\s+(1|2)\b.*\b(E3|E5)\b', bullet, re.IGNORECASE)
|
||||
if m:
|
||||
level = f"L{m.group(1)}"
|
||||
license = m.group(2).upper()
|
||||
profiles.add((level, license))
|
||||
else:
|
||||
level = f"L{m.group(2)}"
|
||||
license = m.group(1).upper()
|
||||
profiles.add((level, license))
|
||||
return profiles
|
||||
|
||||
|
||||
def format_profiles(profiles: set[tuple[str, str]]) -> str:
|
||||
"""Format profile set as compact badge string."""
|
||||
if not profiles:
|
||||
return ""
|
||||
return "[" + ", ".join(f"{lvl}·{lic}" for lvl, lic in sorted(profiles)) + "]"
|
||||
|
||||
|
||||
def matches_filter(profiles: set[tuple[str, str]], level_filter: str, license_filter: str) -> bool:
|
||||
"""Check if a control's profiles match the requested level/license filters.
|
||||
A control matches if at least one of its (level, license) tuples matches both filters.
|
||||
"""
|
||||
if not profiles:
|
||||
return True # If we can't parse profiles, include by default
|
||||
for lvl, lic in profiles:
|
||||
level_ok = level_filter == 'Both' or level_filter == lvl
|
||||
license_ok = license_filter == 'Both' or license_filter == lic
|
||||
if level_ok and license_ok:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_pdf(pdf_path: str) -> list[dict]:
|
||||
"""Extract and parse all recommendations from the PDF."""
|
||||
reader = PdfReader(pdf_path)
|
||||
full_text = ""
|
||||
for page in reader.pages:
|
||||
full_text += "\n" + (page.extract_text() or "")
|
||||
|
||||
m = re.search(r'Profile Applicability:\s*\n\s*•\s*E3', full_text)
|
||||
content_start = m.start() if m else 0
|
||||
content = full_text[content_start:]
|
||||
content = re.sub(r'\nPage \d+\s*\n', '\n', content)
|
||||
|
||||
section_headers = {
|
||||
'Overview', 'Groups', 'Devices', 'Enterprise apps', 'External Identities',
|
||||
'User experiences', 'Authentication Methods', 'Password reset', 'Identity Protection',
|
||||
'Conditional Access', 'Protection', 'Hybrid management', 'Audit', 'Mail flow',
|
||||
'Roles', 'Mobile Device Management', 'Application Permissions', 'Settings',
|
||||
'Teams & groups', 'Users', 'External sharing', 'Guest access', 'Device access',
|
||||
'User risk', 'Sign-in risk', 'Access reviews', 'Privileged Identity Management',
|
||||
'Administration center', 'Email and collaboration', 'Tenant settings',
|
||||
'Meetings', 'Messaging', 'Teams and channels', 'App permissions',
|
||||
'External access', 'Data sharing', 'File sharing', 'Site settings',
|
||||
'Service principals', 'Workspaces', 'External domains', 'External emails',
|
||||
'Meeting policies', 'Calling policies', 'Teams policies', 'Channel policies',
|
||||
'App setup policies', 'Permission policies', 'Update policies',
|
||||
'Compliance policies', 'Retention policies', 'Sensitivity labels',
|
||||
'Data loss prevention', 'Information barriers', 'Communication compliance',
|
||||
'Insider risk management', 'Records management', 'eDiscovery',
|
||||
'Customer Lockbox', 'Audit log', 'Reports', 'Alerts',
|
||||
'Anti-spam', 'Anti-malware', 'Anti-phishing', 'Safe Attachments',
|
||||
'Safe Links', 'Outbound spam', 'Connection filter', 'Mail flow rules',
|
||||
'Transport rules', 'Journal rules', 'Data connectors',
|
||||
'Sensitivity label policies', 'Auto-labeling policies',
|
||||
'Information protection', 'Data governance', 'Compliance Manager',
|
||||
'Service assurance', 'Health', 'Message center', 'Adoption Score',
|
||||
'Usage reports', 'Productivity Score', 'Org settings',
|
||||
'Security & Privacy', 'Organization profile', 'Partner relationships',
|
||||
'Billing', 'Purchase services', 'Subscriptions', 'Licenses',
|
||||
'Payment methods', 'Billing notifications', 'Invoice',
|
||||
'Active users', 'Deleted users', 'Guest users',
|
||||
'Contacts', 'Sign-in options',
|
||||
'Custom domain names', 'DNS records', 'Domain settings',
|
||||
'Shared mailboxes', 'Resource mailboxes', 'Distribution groups',
|
||||
'Dynamic distribution groups', 'Mail-enabled security groups',
|
||||
'Office 365 groups', 'Security groups', 'Mail contacts',
|
||||
'Migration', 'Data migration', 'IMAP migration',
|
||||
'Cutover migration', 'Staged migration', 'Minimal hybrid',
|
||||
'Express migration', 'Cross-tenant migration',
|
||||
'Setup', 'Connectors',
|
||||
'Azure AD', 'Support',
|
||||
'Training', 'Policies', 'Resources', 'Mail',
|
||||
'Sites', 'Apps', 'Power Platform',
|
||||
'Dynamics 365', 'Azure', 'Microsoft 365',
|
||||
'Intune', 'Entra', 'Exchange', 'SharePoint',
|
||||
'OneDrive', 'Power BI', 'Power Apps',
|
||||
'Power Automate', 'Power Virtual Agents', 'Copilot',
|
||||
}
|
||||
|
||||
pa_positions = [m.start() for m in re.finditer(r'Profile Applicability:', content)]
|
||||
recommendations = []
|
||||
|
||||
for i, pa_pos in enumerate(pa_positions):
|
||||
window_start = max(0, pa_pos - 800)
|
||||
window = content[window_start:pa_pos]
|
||||
|
||||
title_match = None
|
||||
for m in re.finditer(r'(\d+\.\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
|
||||
title_match = m
|
||||
if not title_match:
|
||||
for m in re.finditer(r'(\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
|
||||
title_match = m
|
||||
|
||||
if not title_match:
|
||||
continue
|
||||
|
||||
control_num = title_match.group(1)
|
||||
title = title_match.group(2).replace('\n', ' ').strip()
|
||||
title = re.sub(r'\s+', ' ', title)
|
||||
status = title_match.group(3)
|
||||
|
||||
if title in section_headers:
|
||||
continue
|
||||
|
||||
rec_start = title_match.start() + window_start
|
||||
rec_end = pa_positions[i + 1] if i + 1 < len(pa_positions) else len(content)
|
||||
chunk = content[rec_start:rec_end]
|
||||
|
||||
def extract_field(field_name: str, chunk_text: str) -> str | None:
|
||||
pattern = re.compile(
|
||||
re.escape(field_name) + r':\s*\n?\s*(.*?)(?=\n\s*[A-Z][a-zA-Z\s]+:\s*\n|\Z)',
|
||||
re.DOTALL
|
||||
)
|
||||
m = pattern.search(chunk_text)
|
||||
if m:
|
||||
val = m.group(1).strip()
|
||||
val = re.sub(r'\s+', ' ', val)
|
||||
return val
|
||||
return None
|
||||
|
||||
rec = {
|
||||
'control': control_num,
|
||||
'title': title,
|
||||
'status': status,
|
||||
'profile_applicability': extract_field('Profile Applicability', chunk),
|
||||
'description': extract_field('Description', chunk),
|
||||
'rationale': extract_field('Rationale', chunk),
|
||||
'impact': extract_field('Impact', chunk),
|
||||
'default_value': extract_field('Default Value', chunk),
|
||||
}
|
||||
|
||||
rem_match = re.search(r'Remediation:\s*(.*?)(?=Audit:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
|
||||
if rem_match:
|
||||
rec['remediation'] = re.sub(r'\s+', ' ', rem_match.group(1))[:1000]
|
||||
|
||||
audit_match = re.search(r'Audit:\s*(.*?)(?=Remediation:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
|
||||
if audit_match:
|
||||
rec['audit'] = re.sub(r'\s+', ' ', audit_match.group(1))[:1000]
|
||||
|
||||
recommendations.append(rec)
|
||||
|
||||
seen = set()
|
||||
unique = []
|
||||
for r in recommendations:
|
||||
if r['control'] not in seen:
|
||||
seen.add(r['control'])
|
||||
unique.append(r)
|
||||
|
||||
return unique
|
||||
|
||||
|
||||
def generate_yaml(recommendations: list[dict], prefix: str, level_filter: str = 'Both', license_filter: str = 'Both') -> str:
|
||||
"""Generate YAML baseline from parsed recommendations."""
|
||||
lines = []
|
||||
lines.append("# =====================================================================")
|
||||
lines.append("# CIS Microsoft 365 Foundations Benchmark v7.0.0 (Draft)")
|
||||
lines.append("# GENERATED from PDF — review before deploying")
|
||||
lines.append("# =====================================================================")
|
||||
lines.append("")
|
||||
lines.append("baseline:")
|
||||
lines.append(f' name: CIS-M365-v7-Generated')
|
||||
lines.append(' conflictResolution: Skip')
|
||||
lines.append(' whatIf: false')
|
||||
lines.append("")
|
||||
lines.append(' tenantMutation:')
|
||||
lines.append(f' prefix: "{prefix}"')
|
||||
lines.append("")
|
||||
lines.append(' groups:')
|
||||
lines.append(' - displayName: "CIS-BreakGlass"')
|
||||
lines.append(' mailNickname: "CISBreakGlass"')
|
||||
lines.append(' securityEnabled: true')
|
||||
lines.append(' - displayName: "CIS-Pilot-Users"')
|
||||
lines.append(' mailNickname: "CISPilotUsers"')
|
||||
lines.append(' securityEnabled: true')
|
||||
lines.append("")
|
||||
lines.append(' tenantConfig:')
|
||||
|
||||
section_names = {
|
||||
'1': 'adminCenter',
|
||||
'2': 'defender',
|
||||
'3': 'purview',
|
||||
'5': 'entraId',
|
||||
'6': 'exchange',
|
||||
'7': 'sharePoint',
|
||||
'8': 'teams',
|
||||
'9': 'powerBI',
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# COMPREHENSIVE CONTROL MAPPINGS
|
||||
# =====================================================================
|
||||
|
||||
# Simple scalar/boolean mappings: control -> (yaml_section, yaml_key, value)
|
||||
simple_mappings = {
|
||||
# --- Section 1: Admin Center ---
|
||||
'1.3.1': ('adminCenter', 'passwordExpiration', 'NeverExpire'),
|
||||
'1.3.2': ('adminCenter', 'idleSessionTimeoutHours', 3),
|
||||
'1.3.4': ('adminCenter', 'restrictUserOwnedApps', True),
|
||||
'1.3.5': ('adminCenter', 'formsPhishingProtection', True),
|
||||
'1.3.6': ('adminCenter', 'customerLockbox', True),
|
||||
'1.3.7': ('adminCenter', 'restrictThirdPartyStorage', True),
|
||||
'1.3.9': ('adminCenter', 'restrictSharedBookings', True),
|
||||
'1.3.3': ('adminCenter', 'externalCalendarSharing', 'Disabled'),
|
||||
|
||||
# --- Section 5: Entra ID ---
|
||||
'5.1.2.2': ('entraId', 'blockUserConsent', True),
|
||||
'5.1.2.3': ('entraId', 'blockTenantCreation', True),
|
||||
'5.1.2.4': ('entraId', 'restrictAdminCenterAccess', True),
|
||||
'5.1.2.6': ('entraId', 'disableLinkedIn', True),
|
||||
'5.1.3.1': ('entraId', 'blockSecurityGroupCreation', True),
|
||||
'5.1.3.4': ('entraId', 'blockM365GroupCreation', True),
|
||||
'5.1.4.1': ('entraId', 'restrictDeviceJoin', True),
|
||||
'5.1.4.2': ('entraId', 'maxDevicesPerUser', 5),
|
||||
'5.1.4.3': ('entraId', 'gaLocalAdminDisabled', True),
|
||||
'5.1.4.4': ('entraId', 'limitLocalAdminAssignment', True),
|
||||
'5.1.4.5': ('entraId', 'enableLAPS', True),
|
||||
'5.1.4.6': ('entraId', 'restrictBitLockerRecovery', True),
|
||||
'5.1.5.1': ('entraId', 'blockUserConsent', True),
|
||||
'5.1.5.2': ('entraId', 'enableAdminConsentWorkflow', True),
|
||||
'5.1.5.3': ('entraId', 'blockPasswordCredentials', True),
|
||||
'5.1.5.4': ('entraId', 'maxPasswordLifetimeDays', 180),
|
||||
'5.1.5.5': ('entraId', 'systemGeneratedPasswords', True),
|
||||
'5.1.5.6': ('entraId', 'maxCertificateLifetimeDays', 180),
|
||||
'5.1.6.1': ('entraId', 'restrictCollaborationDomains', True),
|
||||
'5.1.6.2': ('entraId', 'restrictGuestAccess', True),
|
||||
'5.1.6.3': ('entraId', 'limitGuestInvitations', True),
|
||||
'5.1.8.1': ('entraId', 'enablePasswordHashSync', True),
|
||||
'5.2.3.1': ('entraId', 'authenticatorNumberMatching', True),
|
||||
'5.2.3.4': ('entraId', 'mfaCapableUsers', True),
|
||||
'5.2.3.5': ('entraId', 'disableWeakAuthMethods', True),
|
||||
'5.2.3.6': ('entraId', 'systemPreferredMFA', True),
|
||||
'5.2.3.7': ('entraId', 'disableEmailOTP', True),
|
||||
'5.2.3.8': ('entraId', 'lockoutThreshold', 10),
|
||||
'5.2.3.9': ('entraId', 'lockoutDurationSeconds', 60),
|
||||
'5.2.3.10': ('entraId', 'disableAuthenticatorCompanionApps', True),
|
||||
'5.3.1': ('entraId', 'pimRoleActivationRequired', True),
|
||||
'5.3.2': ('entraId', 'accessReviewsForGuests', True),
|
||||
'5.3.3': ('entraId', 'accessReviewsForPrivilegedRoles', True),
|
||||
'5.3.4': ('entraId', 'requireApprovalForGAActivation', True),
|
||||
'5.3.5': ('entraId', 'requireApprovalForPRAActivation', True),
|
||||
|
||||
# --- Section 6: Exchange ---
|
||||
'6.1.1': ('exchange', 'enableMailboxAuditOrgWide', True),
|
||||
'6.1.2': ('exchange', 'configureMailboxAuditActions', True),
|
||||
'6.1.3': ('exchange', 'disableAuditBypass', True),
|
||||
'6.2.1': ('exchange', 'blockExternalForwarding', True),
|
||||
'6.2.2': ('exchange', 'noDomainWhitelistTransportRules', True),
|
||||
'6.2.3': ('exchange', 'enableExternalSenderBanner', True),
|
||||
'6.3.1': ('exchange', 'blockOutlookAddIns', True),
|
||||
'6.3.2': ('exchange', 'disablePersonalEmailAccounts', True),
|
||||
'6.5.1': ('exchange', 'enableModernAuthExchange', True),
|
||||
'6.5.2': ('exchange', 'enableMailTips', True),
|
||||
'6.5.3': ('exchange', 'restrictAdditionalStorageProviders', True),
|
||||
'6.5.4': ('exchange', 'disableSMTPAuth', True),
|
||||
'6.5.5': ('exchange', 'rejectDirectSend', True),
|
||||
'1.2.2': ('exchange', 'blockSharedMailboxSignIn', True),
|
||||
'2.1.12': ('exchange', 'connectionFilterIPAllowListEmpty', True),
|
||||
'2.1.13': ('exchange', 'connectionFilterSafeListOff', True),
|
||||
'2.1.14': ('exchange', 'inboundAntiSpamNoAllowedDomains', True),
|
||||
'2.1.15': ('exchange', 'outboundAntiSpamLimits', True),
|
||||
|
||||
# --- Section 7: SharePoint ---
|
||||
'7.2.1': ('sharePoint', 'requireModernAuthSharePoint', True),
|
||||
'7.2.2': ('sharePoint', 'enableAADB2BIntegration', True),
|
||||
'7.2.3': ('sharePoint', 'sharePointExternalSharing', 'Disabled'),
|
||||
'7.2.4': ('sharePoint', 'oneDriveExternalSharing', 'Disabled'),
|
||||
'7.2.5': ('sharePoint', 'preventGuestResharing', True),
|
||||
'7.2.6': ('sharePoint', 'restrictSharePointExternalSharing', True),
|
||||
'7.2.7': ('sharePoint', 'restrictLinkSharing', True),
|
||||
'7.2.8': ('sharePoint', 'restrictSharingBySecurityGroup', True),
|
||||
'7.2.9': ('sharePoint', 'guestAccessExpirationDays', 30),
|
||||
'7.2.10': ('sharePoint', 'restrictReauthenticationVerificationCode', True),
|
||||
'7.2.11': ('sharePoint', 'defaultSharingLinkPermission', 'View'),
|
||||
'7.3.1': ('sharePoint', 'disallowInfectedFileDownload', True),
|
||||
|
||||
# --- Section 8: Teams ---
|
||||
'8.1.1': ('teams', 'restrictExternalFileSharing', True),
|
||||
'8.1.2': ('teams', 'blockChannelEmail', True),
|
||||
'8.2.1': ('teams', 'restrictExternalDomains', True),
|
||||
'8.2.2': ('teams', 'disableUnmanagedUserCommunication', True),
|
||||
'8.2.3': ('teams', 'blockExternalUserInitiation', True),
|
||||
'8.2.4': ('teams', 'blockTrialTenantCommunication', True),
|
||||
'8.5.1': ('teams', 'allowAnonymousUsersToJoinMeeting', False),
|
||||
'8.5.2': ('teams', 'allowAnonymousUsersToStartMeeting', False),
|
||||
'8.5.3': ('teams', 'orgOnlyBypassLobby', True),
|
||||
'8.5.4': ('teams', 'dialInCantBypassLobby', True),
|
||||
'8.5.5': ('teams', 'noAnonymousMeetingChat', True),
|
||||
'8.5.6': ('teams', 'onlyOrganizersCanPresent', True),
|
||||
'8.5.7': ('teams', 'noExternalControl', True),
|
||||
'8.5.8': ('teams', 'externalMeetingChatOff', True),
|
||||
'8.5.9': ('teams', 'meetingRecordingOffByDefault', True),
|
||||
'8.6.1': ('teams', 'enableSecurityConcernsReporting', True),
|
||||
|
||||
# --- Section 9: Power BI ---
|
||||
'9.1.1': ('powerBI', 'restrictGuestAccess', True),
|
||||
'9.1.2': ('powerBI', 'restrictExternalInvitations', True),
|
||||
'9.1.3': ('powerBI', 'restrictGuestContentAccess', True),
|
||||
'9.1.4': ('powerBI', 'restrictPublishToWeb', True),
|
||||
'9.1.5': ('powerBI', 'disableRPythonVisuals', True),
|
||||
'9.1.6': ('powerBI', 'enableSensitivityLabels', True),
|
||||
'9.1.7': ('powerBI', 'restrictShareableLinks', True),
|
||||
'9.1.8': ('powerBI', 'restrictExternalDataSharing', True),
|
||||
'9.1.9': ('powerBI', 'blockResourceKeyAuth', True),
|
||||
'9.1.10': ('powerBI', 'restrictServicePrincipalAPIAccess', True),
|
||||
'9.1.11': ('powerBI', 'blockServicePrincipalProfiles', True),
|
||||
'9.1.12': ('powerBI', 'restrictServicePrincipalWorkspaceCreation', True),
|
||||
|
||||
# --- Section 3: Purview ---
|
||||
'3.1.1': ('purview', 'enableAuditLogSearch', True),
|
||||
}
|
||||
|
||||
# Defender policy mappings
|
||||
defender_policies = {
|
||||
'2.1.1': ('safeLinks', {
|
||||
'name': 'SafeLinks-Default',
|
||||
'enabled': True,
|
||||
'trackClicks': True,
|
||||
'allowClickThrough': False,
|
||||
'scanUrls': True,
|
||||
'enableForInternalSenders': True,
|
||||
}),
|
||||
'2.1.2': ('antiMalware', {
|
||||
'name': 'AntiMalware-Default',
|
||||
'enabled': True,
|
||||
'enableInternalNotifications': True,
|
||||
'fileTypes': ['ace', 'ani', 'app', 'docm', 'exe', 'jar', 'jnlp', 'msi', 'ps1', 'scr', 'vbs', 'wsf'],
|
||||
}),
|
||||
'2.1.3': ('antiMalware', {
|
||||
'name': 'AntiMalware-InternalNotify',
|
||||
'enabled': True,
|
||||
'enableInternalNotifications': True,
|
||||
}),
|
||||
'2.1.4': ('safeAttachments', {
|
||||
'name': 'SafeAttachments-Default',
|
||||
'enabled': True,
|
||||
'action': 'Block',
|
||||
'quarantineMessages': True,
|
||||
}),
|
||||
'2.1.5': ('safeAttachments', {
|
||||
'name': 'SafeAttachments-SPO-Teams',
|
||||
'enabled': True,
|
||||
'action': 'Block',
|
||||
'enableForSharePoint': True,
|
||||
'enableForTeams': True,
|
||||
}),
|
||||
'2.1.6': ('antiSpam', {
|
||||
'name': 'AntiSpam-Notify-Admins',
|
||||
'enabled': True,
|
||||
'notifyAdmins': True,
|
||||
}),
|
||||
'2.1.7': ('antiPhish', {
|
||||
'name': 'AntiPhish-Default',
|
||||
'enabled': True,
|
||||
'enableMailboxIntelligence': True,
|
||||
'enableSpoofIntelligence': True,
|
||||
'mailboxIntelligenceProtectionAction': 'Quarantine',
|
||||
}),
|
||||
'2.1.11': ('antiMalware', {
|
||||
'name': 'AntiMalware-Comprehensive',
|
||||
'enabled': True,
|
||||
'enableFileFilter': True,
|
||||
}),
|
||||
'2.4.1': ('priorityAccount', {'enabled': True}),
|
||||
'2.4.2': ('priorityAccount', {'strictProtection': True}),
|
||||
'2.4.4': ('zap', {'enabledForTeams': True}),
|
||||
}
|
||||
|
||||
# Draft YAML blocks for tenant-specific controls (commented out)
|
||||
draft_blocks = {
|
||||
'1.1.3': [
|
||||
" # ASSESSMENT-ONLY: Report current global admin count; cannot auto-remediate",
|
||||
" # assessment:",
|
||||
" # control: \"1.1.3\"",
|
||||
" # name: \"GlobalAdminCount\"",
|
||||
" # minAdmins: 2",
|
||||
" # maxAdmins: 4",
|
||||
],
|
||||
'1.1.4': [
|
||||
" # ASSESSMENT-ONLY: Report admin license footprint; cannot auto-remediate",
|
||||
" # assessment:",
|
||||
" # control: \"1.1.4\"",
|
||||
" # name: \"AdminLicenseFootprint\"",
|
||||
" # allowedSkus: [\"AAD_PREMIUM_P2\", \"ENTERPRISEPACK\", \"SPE_E5\"]",
|
||||
],
|
||||
'1.2.1': [
|
||||
" # ASSESSMENT-ONLY: Review public groups; cannot auto-remediate",
|
||||
" # assessment:",
|
||||
" # control: \"1.2.1\"",
|
||||
" # name: \"PublicGroupReview\"",
|
||||
" # visibilityFilter: \"Public\"",
|
||||
],
|
||||
'3.2.1': [
|
||||
" # DRAFT: Uncomment and customize DLP policies for your environment",
|
||||
" # dlpPolicies:",
|
||||
" # - name: \"CIS-DLP-Financial-Data\"",
|
||||
" # enabled: true",
|
||||
" # mode: \"Enable\"",
|
||||
" # locations:",
|
||||
" # - type: \"Exchange\"",
|
||||
" # - type: \"SharePoint\"",
|
||||
" # - type: \"OneDrive\"",
|
||||
" # rules:",
|
||||
" # - name: \"Detect-Credit-Cards\"",
|
||||
" # sensitiveInfoTypes: [\"Credit Card Number\"]",
|
||||
" # actions: [\"BlockWithOverride\"]",
|
||||
" # userNotification: true",
|
||||
" # - name: \"CIS-DLP-PII\"",
|
||||
" # enabled: true",
|
||||
" # mode: \"Enable\"",
|
||||
" # locations:",
|
||||
" # - type: \"TeamsChat\"",
|
||||
" # - type: \"TeamsChannel\"",
|
||||
" # rules:",
|
||||
" # - name: \"Detect-SSN\"",
|
||||
" # sensitiveInfoTypes: [\"U.S. Social Security Number\"]",
|
||||
" # actions: [\"BlockWithOverride\"]",
|
||||
" # userNotification: true",
|
||||
],
|
||||
'3.2.2': [
|
||||
" # DRAFT: Uncomment and customize Teams DLP policy",
|
||||
" # dlpPolicies:",
|
||||
" # - name: \"CIS-DLP-Teams\"",
|
||||
" # enabled: true",
|
||||
" # mode: \"Enable\"",
|
||||
" # locations:",
|
||||
" # - type: \"TeamsChat\"",
|
||||
" # - type: \"TeamsChannel\"",
|
||||
" # rules:",
|
||||
" # - name: \"Teams-Detect-PII\"",
|
||||
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
|
||||
" # actions: [\"BlockWithOverride\"]",
|
||||
" # userNotification: true",
|
||||
],
|
||||
'3.2.3': [
|
||||
" # DRAFT: Uncomment and customize Copilot DLP policy",
|
||||
" # dlpPolicies:",
|
||||
" # - name: \"CIS-DLP-Copilot\"",
|
||||
" # enabled: true",
|
||||
" # mode: \"Enable\"",
|
||||
" # locations:",
|
||||
" # - type: \"TeamsChat\"",
|
||||
" # - type: \"TeamsChannel\"",
|
||||
" # rules:",
|
||||
" # - name: \"Copilot-Detect-Sensitive\"",
|
||||
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
|
||||
" # actions: [\"BlockWithOverride\"]",
|
||||
" # userNotification: true",
|
||||
],
|
||||
'3.3.1': [
|
||||
" # DRAFT: Uncomment and customize sensitivity labels for your organization",
|
||||
" # sensitivityLabels:",
|
||||
" # - name: \"Internal\"",
|
||||
" # displayName: \"Internal\"",
|
||||
" # priority: 1",
|
||||
" # enabled: true",
|
||||
" # labelAction: \"Encrypt\"",
|
||||
" # - name: \"Confidential\"",
|
||||
" # displayName: \"Confidential\"",
|
||||
" # priority: 2",
|
||||
" # enabled: true",
|
||||
" # labelAction: \"Encrypt\"",
|
||||
" # sensitivityLabelPolicies:",
|
||||
" # - name: \"CIS-Label-Policy-Default\"",
|
||||
" # enabled: true",
|
||||
" # labels: [\"Internal\", \"Confidential\"]",
|
||||
" # defaultLabel: \"Internal\"",
|
||||
],
|
||||
}
|
||||
|
||||
def format_val(val):
|
||||
if isinstance(val, str):
|
||||
return f'"{val}"'
|
||||
elif isinstance(val, bool):
|
||||
return str(val).lower()
|
||||
elif isinstance(val, list):
|
||||
return '[' + ', '.join(f'"{v}"' for v in val) + ']'
|
||||
return str(val)
|
||||
|
||||
def write_simple_section(sec_num, sec_name, sec_recs):
|
||||
if not sec_recs:
|
||||
return
|
||||
lines.append("")
|
||||
lines.append(f" # ===============================================================")
|
||||
lines.append(f" # Section {sec_num}: {sec_name}")
|
||||
lines.append(f" # ===============================================================")
|
||||
lines.append(f" {sec_name}:")
|
||||
|
||||
for r in sec_recs:
|
||||
ctrl = r['control']
|
||||
title = r['title']
|
||||
status = r['status']
|
||||
profiles = parse_profiles(r.get('profile_applicability'))
|
||||
profile_badge = format_profiles(profiles)
|
||||
|
||||
# Filter by level/license
|
||||
if not matches_filter(profiles, level_filter, license_filter):
|
||||
continue
|
||||
|
||||
# Skip CA policies — they are handled in the conditionalAccess section
|
||||
if ctrl.startswith('5.2.2.'):
|
||||
continue
|
||||
# Skip on-prem AD password protection — hybrid only
|
||||
if ctrl == '5.2.3.3':
|
||||
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
|
||||
lines.append(f" # NOTE: Hybrid-only control — requires on-premises Active Directory")
|
||||
continue
|
||||
# Banned passwords — add inline with external file support
|
||||
if ctrl == '5.2.3.2':
|
||||
lines.append(f" # {ctrl} {profile_badge}: {title}")
|
||||
lines.append(f" # Option A: Inline list")
|
||||
lines.append(f" bannedPasswords:")
|
||||
lines.append(f" - \"Contoso\"")
|
||||
lines.append(f" - \"Password\"")
|
||||
lines.append(f" - \"Welcome\"")
|
||||
lines.append(f" - \"Admin\"")
|
||||
lines.append(f" - \"Login\"")
|
||||
lines.append(f" - \"Microsoft\"")
|
||||
lines.append(f" - \"Office365\"")
|
||||
lines.append(f" # Option B: External file (one password per line)")
|
||||
lines.append(f" # bannedPasswordsFile: \"./banned-passwords.txt\"")
|
||||
continue
|
||||
|
||||
if status == 'Manual':
|
||||
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
|
||||
rem = r.get('remediation', '')
|
||||
hint = rem[:120] + '...' if len(rem) > 120 else rem
|
||||
if hint:
|
||||
lines.append(f" # HINT: {hint}")
|
||||
lines.append(f" # TODO: Implement manually per PDF instructions")
|
||||
continue
|
||||
|
||||
if ctrl in simple_mappings:
|
||||
sec, key, val = simple_mappings[ctrl]
|
||||
lines.append(f" # {ctrl} {profile_badge}: {title}")
|
||||
lines.append(f" {key}: {format_val(val)}")
|
||||
elif ctrl in draft_blocks:
|
||||
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
|
||||
for line in draft_blocks[ctrl]:
|
||||
lines.append(line)
|
||||
else:
|
||||
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
|
||||
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
|
||||
|
||||
# Write non-defender, non-CA sections
|
||||
for sec_num in ['1', '5', '6', '7', '8', '9', '3']:
|
||||
sec_name = section_names[sec_num]
|
||||
sec_recs = [r for r in recommendations if r['control'].split('.')[0] == sec_num]
|
||||
write_simple_section(sec_num, sec_name, sec_recs)
|
||||
|
||||
# Defender section (with proper policy structures)
|
||||
def_recs = [r for r in recommendations if r['control'].split('.')[0] == '2']
|
||||
if def_recs:
|
||||
lines.append("")
|
||||
lines.append(" # ===============================================================")
|
||||
lines.append(" # Section 2: Defender for Office 365")
|
||||
lines.append(" # ===============================================================")
|
||||
lines.append(" defender:")
|
||||
|
||||
for r in def_recs:
|
||||
ctrl = r['control']
|
||||
title = r['title']
|
||||
status = r['status']
|
||||
profiles = parse_profiles(r.get('profile_applicability'))
|
||||
profile_badge = format_profiles(profiles)
|
||||
|
||||
if not matches_filter(profiles, level_filter, license_filter):
|
||||
continue
|
||||
|
||||
if status == 'Manual':
|
||||
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
|
||||
continue
|
||||
|
||||
if ctrl in defender_policies:
|
||||
policy_type, policy_def = defender_policies[ctrl]
|
||||
lines.append(f" # {ctrl} {profile_badge}: {title}")
|
||||
lines.append(f" {policy_type}:")
|
||||
for k, v in policy_def.items():
|
||||
lines.append(f" {k}: {format_val(v)}")
|
||||
elif ctrl in ['2.1.8', '2.1.9', '2.1.10']:
|
||||
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
|
||||
lines.append(f" # NOTE: DNS-level control — configure via DNS provider, not M365 tenant")
|
||||
elif ctrl in simple_mappings:
|
||||
sec, key, val = simple_mappings[ctrl]
|
||||
lines.append(f" # {ctrl} {profile_badge}: {title}")
|
||||
lines.append(f" {key}: {format_val(val)}")
|
||||
else:
|
||||
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
|
||||
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
|
||||
|
||||
# Conditional Access section
|
||||
ca_recs = [r for r in recommendations if r['control'].startswith('5.2.2.')]
|
||||
if ca_recs:
|
||||
lines.append("")
|
||||
lines.append(" # ===============================================================")
|
||||
lines.append(" # Section 5.2.2: Conditional Access")
|
||||
lines.append(" # ===============================================================")
|
||||
lines.append(" conditionalAccess:")
|
||||
lines.append(" reportOnly: true")
|
||||
lines.append(" breakGlassGroup: \"CIS-BreakGlass\"")
|
||||
lines.append(" policies:")
|
||||
|
||||
for r in ca_recs:
|
||||
ctrl = r['control']
|
||||
title = r['title']
|
||||
status = r['status']
|
||||
profiles = parse_profiles(r.get('profile_applicability'))
|
||||
profile_badge = format_profiles(profiles)
|
||||
|
||||
if not matches_filter(profiles, level_filter, license_filter):
|
||||
continue
|
||||
|
||||
if status == 'Manual':
|
||||
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
|
||||
continue
|
||||
|
||||
name = re.sub(r'[^a-zA-Z0-9\s]', '', title)
|
||||
name = re.sub(r'\s+', '-', name)
|
||||
name = re.sub(r'-+', '-', name)
|
||||
name = name[:55].strip('-')
|
||||
|
||||
lines.append(f" - name: \"{name}\"")
|
||||
lines.append(f" cisControl: \"{ctrl}\"")
|
||||
lines.append(f" description: \"{title}\"")
|
||||
lines.append(f" state: enabledForReportingButNotEnforced")
|
||||
lines.append(f" conditions:")
|
||||
lines.append(f" applications:")
|
||||
|
||||
t = title.lower()
|
||||
if 'intune enrollment' in t:
|
||||
lines.append(f" includeApplications: [\"0000000a-0000-0000-c000-000000000000\"]")
|
||||
elif 'register security' in t:
|
||||
lines.append(f" includeUserActions: [\"urn:user:registersecurityinfo\"]")
|
||||
else:
|
||||
lines.append(f" includeApplications: [\"All\"]")
|
||||
|
||||
lines.append(f" users:")
|
||||
|
||||
if 'admin' in t or 'administrator' in t:
|
||||
lines.append(f" includeRoles:")
|
||||
lines.append(f" - \"Global Administrator\"")
|
||||
lines.append(f" - \"Privileged Role Administrator\"")
|
||||
lines.append(f" - \"Security Administrator\"")
|
||||
lines.append(f" - \"Exchange Administrator\"")
|
||||
lines.append(f" - \"SharePoint Administrator\"")
|
||||
lines.append(f" - \"Conditional Access Administrator\"")
|
||||
lines.append(f" - \"Application Administrator\"")
|
||||
lines.append(f" - \"Cloud Application Administrator\"")
|
||||
lines.append(f" - \"User Administrator\"")
|
||||
lines.append(f" - \"Helpdesk Administrator\"")
|
||||
lines.append(f" - \"Billing Administrator\"")
|
||||
lines.append(f" - \"Authentication Administrator\"")
|
||||
lines.append(f" - \"Password Administrator\"")
|
||||
lines.append(f" - \"Global Reader\"")
|
||||
else:
|
||||
lines.append(f" includeUsers: [\"All\"]")
|
||||
|
||||
if 'legacy' in t:
|
||||
lines.append(f" clientAppTypes: [\"exchangeActiveSync\", \"other\"]")
|
||||
elif 'device code' in t:
|
||||
lines.append(f" authenticationFlows:")
|
||||
lines.append(f" deviceCodeFlow:")
|
||||
lines.append(f" isEnabled: true")
|
||||
elif 'sign-in risk' in t or 'risk' in t:
|
||||
lines.append(f" signInRiskLevels: [\"medium\", \"high\"]")
|
||||
elif 'named location' in t or 'geographic' in t:
|
||||
lines.append(f" # TODO: Define named locations in Entra admin center")
|
||||
|
||||
lines.append(f" grantControls:")
|
||||
|
||||
if 'block' in t and ('legacy' in t or 'device code' in t or 'risk' in t or 'authentication transfer' in t):
|
||||
lines.append(f" builtInControls: [\"block\"]")
|
||||
lines.append(f" operator: \"OR\"")
|
||||
elif 'mfa' in t and 'phishing-resistant' in t:
|
||||
lines.append(f" builtInControls: [\"authenticationStrength\"]")
|
||||
lines.append(f" authenticationStrength:")
|
||||
lines.append(f" id: \"00000000-0000-0000-0000-000000000004\"")
|
||||
lines.append(f" operator: \"OR\"")
|
||||
elif 'mfa' in t or 'multifactor' in t or 'reauthentication' in t or 're-authentication' in t:
|
||||
lines.append(f" builtInControls: [\"mfa\"]")
|
||||
lines.append(f" operator: \"OR\"")
|
||||
elif 'managed device' in t:
|
||||
lines.append(f" builtInControls: [\"compliantDevice\", \"domainJoinedDevice\"]")
|
||||
lines.append(f" operator: \"OR\"")
|
||||
elif 'token protection' in t:
|
||||
lines.append(f" builtInControls: [\"mfa\"]")
|
||||
lines.append(f" operator: \"OR\"")
|
||||
lines.append(f" # TODO: Enable Token Protection via Authentication Strength policy")
|
||||
else:
|
||||
lines.append(f" builtInControls: [\"mfa\"]")
|
||||
lines.append(f" operator: \"OR\"")
|
||||
|
||||
if 'sign-in frequency' in t or 'browser' in t or 'persistent' in t:
|
||||
lines.append(f" sessionControls:")
|
||||
lines.append(f" signInFrequency:")
|
||||
lines.append(f" value: 12")
|
||||
lines.append(f" type: hours")
|
||||
lines.append(f" isEnabled: true")
|
||||
lines.append(f" persistentBrowser:")
|
||||
lines.append(f" mode: never")
|
||||
lines.append(f" isEnabled: true")
|
||||
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: _ConvertFrom-CISPDF.py <pdf_path> <output_path> [prefix] [level] [license]")
|
||||
print(" level: L1 | L2 | Both (default)")
|
||||
print(" license: E3 | E5 | Both (default)")
|
||||
sys.exit(1)
|
||||
|
||||
pdf_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
prefix = sys.argv[3] if len(sys.argv) > 3 else "CIS-v7-"
|
||||
level_filter = sys.argv[4] if len(sys.argv) > 4 else "Both"
|
||||
license_filter = sys.argv[5] if len(sys.argv) > 5 else "Both"
|
||||
|
||||
print(f"Parsing PDF: {pdf_path}")
|
||||
recommendations = parse_pdf(pdf_path)
|
||||
|
||||
auto = sum(1 for r in recommendations if r['status'] == 'Automated')
|
||||
manual = sum(1 for r in recommendations if r['status'] == 'Manual')
|
||||
print(f"Found {len(recommendations)} unique recommendations")
|
||||
print(f" Automated: {auto}")
|
||||
print(f" Manual: {manual}")
|
||||
|
||||
print(f"\nGenerating YAML (level={level_filter}, license={license_filter})...")
|
||||
yaml = generate_yaml(recommendations, prefix, level_filter, license_filter)
|
||||
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(yaml)
|
||||
|
||||
print(f"Written: {output_path}")
|
||||
print(f"Total lines: {len(yaml.splitlines())}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+1104
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user