release: v4.1.0 — restructure entry points, add CIS baselines, reporting tools and fzf hints

- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root;
  Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/
- Add AGENTS.md with project architecture, entry points, and security notes
- Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts
- Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport,
  Export-ObjectInventoryReport) and CA wizard helpers
- Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath,
  and optimized group loading
- Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry
- Update Extensions for Settings Catalog definition auto-export
- Update README with v4.1.0, new entry points and script catalog
- Bump VERSION to 4.1.0
- Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports,
  Settings.json and IntuneManagement.log
This commit is contained in:
2026-06-14 15:24:42 +02:00
parent e333af978c
commit d3e0769799
30 changed files with 8711 additions and 175 deletions
+104 -7
View File
@@ -32,7 +32,9 @@ param(
[string]$SettingsFile,
[switch]$WhatIf
[switch]$WhatIf,
[string]$ReportPath
)
$ErrorActionPreference = "Stop"
@@ -375,11 +377,13 @@ if($effectiveWhatIf) { Write-Host "*** DRY-RUN MODE ENABLED ***" -ForegroundColo
#region Resolve / create groups
$groupCache = @{}
Write-Host "`nLoading group directory..." -ForegroundColor Cyan
$allGroupsData = (Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages).value
if($baseline.ContainsKey("groups") -and $baseline["groups"])
{
Write-Host "`nResolving groups..." -ForegroundColor Cyan
$existingGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
$existingGroups = $existingGroupsResp.value
Write-Host "Resolving baseline groups..." -ForegroundColor Cyan
$existingGroups = $allGroupsData
foreach($grpDef in $baseline["groups"])
{
@@ -412,9 +416,7 @@ if($baseline.ContainsKey("groups") -and $baseline["groups"])
#endregion
#region Pre-load all existing groups for assignment resolution
Write-Host "`nPre-loading group directory..." -ForegroundColor Cyan
$allGroupsResp = Invoke-GraphRequest "/groups?`$select=id,displayName&`$orderby=displayName" -AllPages
foreach($g in $allGroupsResp.value)
foreach($g in $allGroupsData)
{
if(-not $groupCache.ContainsKey($g.displayName))
{
@@ -432,6 +434,7 @@ $stats = @{
Failed = 0
Assigned = 0
}
$policyResults = [System.Collections.Generic.List[PSCustomObject]]::new()
if($baseline.ContainsKey("policies") -and $baseline["policies"])
{
@@ -482,6 +485,9 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
$objectId = $null
$shouldAssign = $false
$outcomeStatus = $null
$outcomeObjectId = $null
if($existingObj)
{
Write-Host " Existing object found: $($existingObj.id)" -ForegroundColor Yellow
@@ -495,22 +501,49 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
$objectId = $existingObj.id
$shouldAssign = $true # still apply assignments to existing object
$stats.Skipped++
$outcomeStatus = "Skipped"; $outcomeObjectId = $existingObj.id
}
elseif($conflictResolution -eq "Update")
{
if($effectiveWhatIf)
{
Write-Host " [WHATIF] Would PATCH existing object $($existingObj.id)" -ForegroundColor Magenta
$outcomeStatus = "WhatIf-Update"
}
else
{
$patchBody = $policyObj | Select-Object * | ConvertTo-Json -Depth 50
$null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content $patchBody
Write-Host " Updated existing object." -ForegroundColor Green
$outcomeStatus = "Updated"
}
$objectId = $existingObj.id
$shouldAssign = $true
$stats.Updated++
$outcomeObjectId = $existingObj.id
}
elseif($conflictResolution -eq "Merge")
{
if($effectiveWhatIf)
{
Write-Host " [WHATIF] Would PATCH (merge) existing object $($existingObj.id)" -ForegroundColor Magenta
$outcomeStatus = "WhatIf-Merge"
}
else
{
$mergeBody = @{}
foreach($prop in $policyObj.PSObject.Properties)
{
$mergeBody[$prop.Name] = $prop.Value
}
$null = Invoke-GraphRequest -Url "$($typeMeta.API)/$($existingObj.id)" -HttpMethod PATCH -Content ($mergeBody | ConvertTo-Json -Depth 50)
Write-Host " Merged into existing object." -ForegroundColor Green
$outcomeStatus = "Merged"
}
$objectId = $existingObj.id
$shouldAssign = $true
$stats.Updated++
$outcomeObjectId = $existingObj.id
}
}
else
@@ -521,6 +554,7 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
$objectId = "WHATIF-NEW"
$shouldAssign = $true
$stats.Created++
$outcomeStatus = "WhatIf-Create"
}
else
{
@@ -530,6 +564,7 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
Write-Host " Created: $objectId" -ForegroundColor Green
$shouldAssign = $true
$stats.Created++
$outcomeStatus = "Created"; $outcomeObjectId = $newObj.id
# Secondary settings upload (EndpointSecurity / DeviceManagementIntents)
if($typeMeta.SettingsAPI)
@@ -556,11 +591,28 @@ if($baseline.ContainsKey("policies") -and $baseline["policies"])
Invoke-DeployAssignments -ObjectId $objectId -TypeMeta $typeMeta -Assignments $policyDef["assignments"] -GroupCache $groupCache -WhatIf:$effectiveWhatIf
$stats.Assigned++
}
$policyResults.Add([PSCustomObject]@{
PolicyName = $mutatedName
Type = $typeName
SourcePath = $sourcePath
ObjectId = $outcomeObjectId
Outcome = $outcomeStatus
Error = $null
})
}
catch
{
Write-Warning "Failed to deploy policy '$sourcePath': $_"
$stats.Failed++
$policyResults.Add([PSCustomObject]@{
PolicyName = $mutatedName
Type = $typeName
SourcePath = $sourcePath
ObjectId = $null
Outcome = "Failed"
Error = $_.Exception.Message
})
}
}
}
@@ -579,4 +631,49 @@ if($effectiveWhatIf)
{
Write-Host "`n*** This was a dry-run (WhatIf). No changes were made. ***" -ForegroundColor Magenta
}
if($policyResults.Count -gt 0)
{
$resolvedReportPath = if($ReportPath) { $ReportPath } else {
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($baselinePathResolved)
Join-Path (Split-Path -Parent $baselinePathResolved) "${baseName}_DeployReport_${ts}.csv"
}
$policyResults | Export-Csv -Path $resolvedReportPath -NoTypeInformation -Force
Write-Host "Report : $resolvedReportPath" -ForegroundColor Cyan
}
if(-not $effectiveWhatIf -and $policyResults.Count -gt 0)
{
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$manifestPolicies = $policyResults | Where-Object { $_.Outcome -in @("Created","Updated","Merged","Skipped") } | ForEach-Object {
$hash = $null
if($_.SourcePath -and (Test-Path $_.SourcePath))
{
$bytes = [System.IO.File]::ReadAllBytes($_.SourcePath)
$hash = [System.BitConverter]::ToString($sha256.ComputeHash($bytes)) -replace '-',''
}
[ordered]@{
policyName = $_.PolicyName
type = $_.Type
objectId = $_.ObjectId
sourcePath = $_.SourcePath
sourceHash = $hash
outcome = $_.Outcome
}
}
$sha256.Dispose()
$manifest = [ordered]@{
baselineName = $baseline["name"]
baselinePath = $baselinePathResolved
tenantId = $TenantId
deployedAt = (Get-Date -Format 'o')
policies = @($manifestPolicies)
}
$manifestPath = [System.IO.Path]::ChangeExtension($baselinePathResolved, "manifest.json")
$manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -Force
Write-Host "Manifest: $manifestPath" -ForegroundColor Cyan
}
#endregion