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
}