public/Invoke-EntraAudit.ps1
|
# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Invoke-EntraAudit { <# .SYNOPSIS Performs a comprehensive Entra ID, Azure, Intune, and M365 security assessment. .DESCRIPTION Invoke-EntraAudit executes a thorough audit of Microsoft cloud identity and device management configuration. It evaluates Conditional Access policies, authentication methods, privileged identity management, application security, federation configuration, tenant settings, Azure IAM, Intune endpoint management, and M365 service configurations. Mapped to recognized security baselines: CIS Microsoft 365, NIST SP 800-53, MITRE ATT&CK, CISA SCuBA, and the EIDSCA Entra ID security-configuration controls. .PARAMETER Categories Specifies which audit categories to run. Default is 'All'. Valid values: All, ConditionalAccess, AuthenticationMethods, PIM, Applications, Federation, TenantConfig, AzureIAM, Intune, M365Services .PARAMETER TenantId The Azure AD / Entra ID tenant ID. .PARAMETER ClientId The application (client) ID for authentication. .PARAMETER CertificateThumbprint Certificate thumbprint for app-only authentication. .PARAMETER ClientSecret Client secret for app-only authentication. .PARAMETER DeviceCode Use device code flow for interactive authentication. .PARAMETER OutputDirectory Directory for report output. Default: per-user data dir + /Guerrilla/Reports (Windows: $env:APPDATA; macOS: ~/Library/Application Support; Linux: $XDG_CONFIG_HOME or ~/.config) .PARAMETER NoReports Skip report generation. .PARAMETER NoDelta Skip the run-over-run comparison and do not record this run in the local run history. .PARAMETER Quiet Suppress console output. .PARAMETER ConfigPath Path to Guerrilla configuration file. .PARAMETER ConfigFile Path to a guerrilla-config.json mission config generated by the Guerrilla website. When provided, resolves credentials from the SecretManagement vault and applies category filtering from the mission config. .EXAMPLE Invoke-EntraAudit -TenantId 'contoso.onmicrosoft.com' -ClientId $appId -ClientSecret $secret .EXAMPLE Invoke-EntraAudit -TenantId $tenantId -ClientId $appId -DeviceCode -Categories ConditionalAccess, PIM #> [CmdletBinding()] param( [ValidateSet('All', 'ConditionalAccess', 'AuthenticationMethods', 'PIM', 'Applications', 'Federation', 'TenantConfig', 'AzureIAM', 'Intune', 'M365Services', 'Eidsca', 'Governance', 'AIAgent')] [string[]]$Categories = @('All'), # Representative user object ID (GUID) for live Conditional Access what-if simulation (EIDCA-015). # When supplied, Guerrilla runs the attack-scenario matrix against the Graph evaluate API. [string]$WhatIfUserId, [string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [securestring]$ClientSecret, [switch]$DeviceCode, [string]$OutputDirectory, [switch]$NoReports, [switch]$NoDelta, [switch]$Quiet, [Alias('RuntimeConfig')] [string]$ConfigPath, [Alias('MissionConfig')] [string]$ConfigFile, [string]$VaultName = 'Guerrilla', [ValidateSet('Guerrilla', 'Professional', 'Slate')] [string]$ReportStyle = 'Professional', [switch]$TestMode ) $vaultName = $VaultName # --- Resolve mission config (guerrilla-config.json) --- if ($ConfigFile) { $missionCfg = Read-MissionConfig -Path $ConfigFile $vaultName = $missionCfg.VaultName # Resolve Microsoft Graph credentials from vault $graphRef = $missionCfg.Config.credentials.references.microsoftGraph if ($graphRef) { if ($graphRef.tenantIdVaultKey -and -not $PSBoundParameters.ContainsKey('TenantId')) { try { $TenantId = Get-GuerrillaCredential -VaultKey $graphRef.tenantIdVaultKey -VaultName $vaultName } catch { Write-Warning "Failed to resolve TenantId from vault: $_" } } if ($graphRef.clientIdVaultKey -and -not $PSBoundParameters.ContainsKey('ClientId')) { try { $ClientId = Get-GuerrillaCredential -VaultKey $graphRef.clientIdVaultKey -VaultName $vaultName } catch { Write-Warning "Failed to resolve ClientId from vault: $_" } } if ($graphRef.vaultKey -and -not $PSBoundParameters.ContainsKey('CertificateThumbprint') -and -not $PSBoundParameters.ContainsKey('ClientSecret')) { try { $secretVal = Get-GuerrillaCredential -VaultKey $graphRef.vaultKey -VaultName $vaultName if ($graphRef.authMethod -eq 'certificate') { $CertificateThumbprint = $secretVal } else { $ClientSecret = $secretVal | ConvertTo-SecureString -AsPlainText -Force } } catch { Write-Warning "Failed to resolve Graph auth credential from vault: $_" } } } # Apply categories from mission config (combine entraAzure + m365 + intune) if (-not $PSBoundParameters.ContainsKey('Categories')) { $missionCats = [System.Collections.Generic.List[string]]::new() foreach ($envKey in @('entraAzure', 'm365', 'intune')) { $envCfg = $missionCfg.EnabledEnvironments[$envKey] if ($envCfg -and $envCfg.audit -and $envCfg.audit.categories) { foreach ($entry in $envCfg.audit.categories.GetEnumerator()) { if ($entry.Value -and $entry.Key -notin $missionCats) { $missionCats.Add($entry.Key) } } } } if ($missionCats.Count -gt 0) { $Categories = @($missionCats) } } } $scanId = [guid]::NewGuid().ToString() $scanStart = [datetime]::UtcNow # --- Load config --- $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath } $config = $null if (Test-Path $cfgPath) { $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable } $outDir = if ($OutputDirectory) { $OutputDirectory } elseif ($config -and $config.output.directory) { $config.output.directory } else { Join-Path (Get-GuerrillaDataRoot) 'Reports' } # Merge TenantId / ClientId from config.json if not already set if (-not $TenantId -and $config -and $config.entra.tenantId) { $TenantId = $config.entra.tenantId } if (-not $ClientId -and $config -and $config.entra.clientId) { $ClientId = $config.entra.clientId } # Final fallback: the safehouse vault under the default keys Set-Safehouse stores # interactively — so a vault-only setup (no mission-config file) scans without # extra parameters. if (-not $TenantId) { $TenantId = Get-SafehouseSecret -VaultKey 'GUERRILLA_GRAPH_TENANT' -VaultName $vaultName } if (-not $ClientId) { $ClientId = Get-SafehouseSecret -VaultKey 'GUERRILLA_GRAPH_CLIENTID' -VaultName $vaultName } if (-not $CertificateThumbprint -and -not $ClientSecret) { $secretVal = Get-SafehouseSecret -VaultKey 'GUERRILLA_GRAPH_SECRET' -VaultName $vaultName if ($secretVal) { $ClientSecret = $secretVal | ConvertTo-SecureString -AsPlainText -Force } } # Validate required parameters (skipped in test mode — no real tenant is contacted) if ($TestMode) { if (-not $TenantId) { $TenantId = 'testmode.tenant' } } else { if (-not $TenantId) { throw 'TenantId is required. Provide -TenantId, store it in the safehouse (Set-Safehouse), -ConfigFile, or set entra.tenantId in config.' } if (-not $ClientId) { throw 'ClientId is required. Provide -ClientId, store it in the safehouse (Set-Safehouse), -ConfigFile, or set entra.clientId in config.' } } # Test mode renders zeroed timestamps for deterministic demo/sample output. $script:GuerrillaTestMode = [bool]$TestMode # --- Operation header --- if (-not $Quiet) { Write-OperationHeader -Operation 'ENTRA AUDIT' -Mode 'Entra / Azure / M365' ` -Target $TenantId -DaysBack 0 } # --- Authenticate to Microsoft Graph --- if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'Authenticating to Microsoft Graph' } # --- Test mode: synthesize an all-FAIL report without touching a real tenant --- if ($TestMode) { if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'TEST MODE — simulating an all-fail report' } $categoriesToRun = @('ConditionalAccess', 'AuthenticationMethods', 'PIM', 'Applications', 'Federation', 'TenantConfig', 'AzureIAM', 'Intune', 'M365Services') $auditData = @{ Errors = @{} } $allFindings = Get-GuerrillaSimulatedFindings -Platform EntraM365 } else { $authParams = @{ TenantId = $TenantId ClientId = $ClientId } if ($CertificateThumbprint) { $authParams['CertificateThumbprint'] = $CertificateThumbprint } if ($ClientSecret) { $authParams['ClientSecret'] = $ClientSecret } if ($DeviceCode) { $authParams['DeviceCode'] = $true } try { $graphToken = Get-GraphAccessToken @authParams ` -Scopes @('https://graph.microsoft.com/.default') } catch { throw "Failed to authenticate to Microsoft Graph: $_" } if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'Authenticated to Microsoft Graph' } # --- Authenticate to Azure Resource Manager (if Azure IAM checks needed) --- $armToken = $null $categoriesToRun = if ($Categories -contains 'All') { @('ConditionalAccess', 'AuthenticationMethods', 'PIM', 'Applications', 'Federation', 'TenantConfig', 'AzureIAM', 'Intune', 'M365Services', 'Eidsca', 'Governance', 'AIAgent') } else { $Categories } if ('AzureIAM' -in $categoriesToRun) { if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'Authenticating to Azure Resource Manager' } try { $armToken = Get-GraphAccessToken @authParams ` -Scopes @('https://management.azure.com/.default') ` -ResourceUrl 'https://management.azure.com' } catch { Write-Warning "ARM authentication failed — Azure IAM checks will be skipped: $_" } } # --- Collect data --- if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'Beginning data collection' } $auditData = Get-EntraAuditData ` -AccessToken $graphToken ` -ArmAccessToken $armToken ` -Categories $Categories ` -WhatIfUserId $WhatIfUserId ` -Quiet:$Quiet # Report collection errors if ($auditData.Errors.Count -gt 0 -and -not $Quiet) { Write-ProgressLine -Phase INFO -Message "Data collection had $($auditData.Errors.Count) error(s)" foreach ($errKey in $auditData.Errors.Keys) { Write-ProgressLine -Phase INFO -Message " $errKey" -Detail $auditData.Errors[$errKey] } } # --- Run checks --- if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'Evaluating security checks' } $allFindings = [System.Collections.Generic.List[PSCustomObject]]::new() $categoryMap = @{ ConditionalAccess = 'Invoke-EntraCAChecks' AuthenticationMethods = 'Invoke-EntraAuthChecks' PIM = 'Invoke-EntraPIMChecks' Applications = 'Invoke-EntraAppChecks' Federation = 'Invoke-EntraFedChecks' TenantConfig = 'Invoke-EntraTenantChecks' Eidsca = 'Invoke-EntraEidscaChecks' Governance = 'Invoke-EntraGovernanceChecks' AIAgent = 'Invoke-EntraAIAgentChecks' AzureIAM = 'Invoke-AzureIAMChecks' Intune = 'Invoke-IntuneChecks' M365Services = @( 'Invoke-M365ExchangeChecks' 'Invoke-M365SharePointChecks' 'Invoke-M365TeamsChecks' 'Invoke-M365DefenderChecks' 'Invoke-M365AuditChecks' 'Invoke-M365PowerPlatformChecks' ) } foreach ($cat in $categoriesToRun) { $funcNames = $categoryMap[$cat] if (-not $funcNames) { continue } # Handle M365Services which maps to multiple check functions foreach ($funcName in @($funcNames)) { if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message "Running $funcName" } if (Get-Command $funcName -ErrorAction SilentlyContinue) { try { $findings = & $funcName -AuditData $auditData foreach ($f in $findings) { $allFindings.Add($f) } } catch { Write-Warning "Check category '$funcName' failed: $_" } } else { if (-not $Quiet) { Write-ProgressLine -Phase INFO -Message "$funcName not available (module not loaded)" } } } } # ENT-4: collapse the ~40 individual "workload module not connected" SKIPs into one # pre-flight banner so the console isn't flooded with per-check skip lines. if (-not $Quiet) { $workloadSkips = @($allFindings | Where-Object { $_.Status -eq 'SKIP' -and $_.CurrentValue -match 'not connected\)' }) if ($workloadSkips.Count -gt 0) { $byModule = @{} foreach ($s in $workloadSkips) { $mod = if ($s.CurrentValue -match '\(([^()]*?)\s+not connected\)') { $Matches[1] } else { 'workload module' } $byModule[$mod] = ($byModule[$mod] + 1) } $parts = @($byModule.GetEnumerator() | Sort-Object Name | ForEach-Object { "$($_.Key) ×$($_.Value)" }) Write-ProgressLine -Phase INFO -Message "$($workloadSkips.Count) workload check(s) skipped — module not connected: $($parts -join ', '). Connect the EXO / Teams / SharePoint / Power Platform admin modules (or run with -IncludeWorkloadModules) to enable them." } } } # end if (-not $TestMode) # --- Score --- $score = Get-AuditPostureScore -Findings @($allFindings) # --- Run-over-run comparison against the local run history --- # Runs regardless of -NoReports (the previous mechanism read old report # files, so it silently vanished whenever reports were suppressed, i.e. # every campaign run). Built now, persisted only at the end of the run. $runRecord = $null $runDiff = $null if (-not $NoDelta -and -not $TestMode) { if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'Comparing against previous run' } try { $runRecord = New-GuerrillaRunRecord -Findings @($allFindings) -Platforms @('Entra') ` -TargetId @($TenantId) -ScanId $scanId -OverallScore $score.OverallScore $previousRun = Get-GuerrillaPreviousRun -Platforms @('Entra') -TargetHash $runRecord.scope.targetHash $runDiff = Compare-GuerrillaRun -Previous $previousRun -Current $runRecord } catch { Write-Warning "Run comparison unavailable: $_" } } # --- Summary --- $scanEnd = [datetime]::UtcNow $scanDuration = $scanEnd - $scanStart $result = [PSCustomObject]@{ PSTypeName = 'Guerrilla.EntraAuditResult' ScanId = $scanId TenantId = $TenantId ScanStart = $scanStart ScanEnd = $scanEnd Duration = $scanDuration Categories = $categoriesToRun Findings = @($allFindings) Score = $score RunComparison = $runDiff DataErrors = $auditData.Errors AuditData = $auditData HtmlReportPath = $null } # --- Console output --- if (-not $Quiet) { Write-EntraReport -Result $result } # --- Reports --- if (-not $NoReports) { if (-not (Test-Path $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } # Test mode uses a zeroed timestamp so report filenames are deterministic. $timestamp = if ($script:GuerrillaTestMode) { '00000000-000000' } else { $scanStart.ToString('yyyyMMdd-HHmmss') } $tenantLabel = $TenantId -replace '[^a-zA-Z0-9]', '_' # Export reports $baseName = "entra-$tenantLabel-$timestamp" $htmlPath = Join-Path $outDir "$baseName.html" if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message 'Generating reports' } try { if (-not $PSBoundParameters.ContainsKey('ReportStyle') -and $config -and $config.output -and ($config.output.reportStyle -in 'Guerrilla', 'Professional', 'Slate')) { $ReportStyle = [string]$config.output.reportStyle } Export-EntraReportHtml -Result $result -OutputPath $htmlPath ` -RunDiff $runDiff ` -Style $ReportStyle ` -Branding (Get-GuerrillaBranding -Config $config) $result.HtmlReportPath = $htmlPath } catch { Write-Warning "HTML report generation failed: $_" } try { Export-EntraReportCsv -Result $result -OutputPath (Join-Path $outDir "$baseName.csv") } catch { Write-Warning "CSV report generation failed: $_" } try { Export-EntraReportJson -Result $result -OutputPath (Join-Path $outDir "$baseName.json") } catch { Write-Warning "JSON report generation failed: $_" } if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message "Reports saved to $outDir" } } # --- Record this completed run: it becomes the next run's baseline --- if ($runRecord) { try { $null = Save-GuerrillaRunRecord -Record $runRecord } catch { Write-Warning "Run history not updated: $_" } } # --- Complete --- if (-not $Quiet) { Write-ProgressLine -Phase ENTRA -Message "Entra audit complete in $([Math]::Round($scanDuration.TotalSeconds, 1))s" } return $result } |