public/Invoke-Campaign.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-Campaign {
    <#
    .SYNOPSIS
        Runs a unified security audit across Google Workspace, Active Directory, and Microsoft Cloud.

    .DESCRIPTION
        Invoke-Campaign orchestrates a full-spectrum security assessment by calling the
        Invoke-GWSAudit (Google Workspace), Invoke-ADAudit (Active Directory), and
        Invoke-EntraAudit (Entra ID / Azure / Intune / M365) audits and combining their
        findings into one unified report.

        Each platform is optional — skip what doesn't apply to your org.

    .PARAMETER Platforms
        Which platforms to audit. Default: auto-detect from provided credentials.
        Valid values: AD, Entra, GWS (legacy values Workspace and Cloud are accepted
        as synonyms for GWS and Entra). -Theaters is accepted as a deprecated alias
        for this parameter.

    .PARAMETER ServiceAccountKeyPath
        Google service account key JSON path (enables Workspace platform).

    .PARAMETER AdminEmail
        Google Workspace admin email (enables Workspace platform).

    .PARAMETER Server
        AD domain controller (enables AD platform). Omit to use current domain.

    .PARAMETER Credential
        AD credentials. Omit to use current user.

    .PARAMETER TenantId
        Entra ID tenant ID (enables Cloud platform).

    .PARAMETER ClientId
        Entra app registration client ID (enables Cloud platform).

    .PARAMETER CertificateThumbprint
        Certificate thumbprint for Entra app-only auth.

    .PARAMETER ClientSecret
        Client secret for Entra app-only auth.

    .PARAMETER DeviceCode
        Use device code flow for Entra interactive auth.

    .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 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.

    .EXAMPLE
        Invoke-Campaign -Platforms Cloud -TenantId $t -ClientId $c -DeviceCode

    .EXAMPLE
        Invoke-Campaign -Platforms AD, Cloud -TenantId $t -ClientId $c -ClientSecret $s

    .EXAMPLE
        Invoke-Campaign -ServiceAccountKeyPath $key -AdminEmail $admin -TenantId $t -ClientId $c -DeviceCode
    #>

    [CmdletBinding()]
    param(
        [Alias('Theaters')]
        [ValidateSet('AD', 'Entra', 'GWS', 'Workspace', 'Cloud')]
        [string[]]$Platforms,

        # ── Google Workspace ──
        [string]$ServiceAccountKeyPath,
        [string]$AdminEmail,
        [string]$TargetOU,

        # ── Active Directory ──
        [string]$Server,
        [pscredential]$Credential,

        # ── Microsoft Cloud ──
        [string]$TenantId,
        [string]$ClientId,
        [string]$CertificateThumbprint,
        [securestring]$ClientSecret,
        [switch]$DeviceCode,

        # ── Shared ──
        [string]$OutputDirectory,
        [switch]$NoDelta,
        [switch]$Quiet,
        [Alias('RuntimeConfig')]
        [string]$ConfigPath,
        [Alias('MissionConfig')]
        [string]$ConfigFile,
        [string]$VaultName = 'Guerrilla',

        [ValidateSet('Guerrilla', 'Professional', 'Slate')]
        [string]$ReportStyle = 'Professional',

        [switch]$TestMode
    )

    # Canonical platform names are AD / Entra / GWS; Workspace and Cloud remain
    # accepted (pre-rename vocabulary) and normalize to the internal values used
    # by the launch blocks below.
    if ($Platforms) {
        $Platforms = @($Platforms | ForEach-Object {
            switch ($_) { 'GWS' { 'Workspace' } 'Entra' { 'Cloud' } default { $_ } }
        } | Select-Object -Unique)
    }

    $tempSaPath = $null
    $vaultName = $VaultName
    # --- Resolve mission config (guerrilla-config.json) ---
    if ($ConfigFile) {
        $missionCfg = Read-MissionConfig -Path $ConfigFile
        $vaultName = $missionCfg.VaultName

        # Determine platforms from enabled environments
        if (-not $PSBoundParameters.ContainsKey('Platforms')) {
            $Platforms = @()
            if ($missionCfg.EnabledEnvironments.ContainsKey('googleWorkspace')) { $Platforms += 'Workspace' }
            if ($missionCfg.EnabledEnvironments.ContainsKey('activeDirectory')) { $Platforms += 'AD' }
            if ($missionCfg.EnabledEnvironments.ContainsKey('entraAzure') -or
                $missionCfg.EnabledEnvironments.ContainsKey('m365') -or
                $missionCfg.EnabledEnvironments.ContainsKey('intune')) { $Platforms += 'Cloud' }
        }

        # Resolve GWS credentials from vault
        $gwsRef = $missionCfg.Config.credentials.references.googleWorkspace
        if ($gwsRef) {
            if (-not $PSBoundParameters.ContainsKey('ServiceAccountKeyPath')) {
                try {
                    $saJson = Get-GuerrillaCredential -VaultKey $gwsRef.vaultKey -VaultName $vaultName
                    $tempSaPath = Join-Path ([System.IO.Path]::GetTempPath()) "guerrilla-sa-$([guid]::NewGuid().ToString('N').Substring(0,8)).json"
                    $saJson | Set-Content -Path $tempSaPath -Encoding UTF8
                    $ServiceAccountKeyPath = $tempSaPath
                } catch {
                    Write-Verbose "GWS service account not in vault — will require explicit parameters."
                }
            }
            if (-not $PSBoundParameters.ContainsKey('AdminEmail')) {
                try {
                    $AdminEmail = Get-GuerrillaCredential -VaultKey "$($gwsRef.vaultKey)_ADMIN_EMAIL" -VaultName $vaultName
                } catch {
                    Write-Verbose "AdminEmail not found in vault."
                }
            }
        }

        # 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 {}
            }
            if ($graphRef.clientIdVaultKey -and -not $PSBoundParameters.ContainsKey('ClientId')) {
                try { $ClientId = Get-GuerrillaCredential -VaultKey $graphRef.clientIdVaultKey -VaultName $vaultName } catch {}
            }
            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 {}
            }
        }

        # Resolve AD credentials from vault
        $adRef = $missionCfg.Config.credentials.references.activeDirectory
        if ($adRef -and $adRef.type -eq 'serviceAccount' -and -not $PSBoundParameters.ContainsKey('Credential')) {
            try {
                $Credential = Get-GuerrillaCredential -VaultKey ($adRef.vaultKey ?? 'GUERRILLA_AD_CREDENTIAL') -VaultName $vaultName
            } catch {}
        }
    }

    # The mission-config path may have staged the vault service-account key to a
    # temp file (guerrilla-sa-*.json). Guarantee it is removed when the scan ends -
    # including on throw - so the private key never lingers in the temp directory.
    try {
        $scanId = [guid]::NewGuid().ToString()
        $scanStart = [datetime]::UtcNow

        # --- Load config ---
        $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath }
        $config = $null
        if ($cfgPath -and (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' }

        # Final fallback: pull any still-missing credentials from the safehouse vault
        # under the default keys Set-Safehouse stores interactively. This runs before
        # platform auto-detection and the per-platform requirement checks below, so a
        # vault-only setup (no mission-config file) drives a full campaign.
        if (-not $ServiceAccountKeyPath) {
            $saJson = Get-SafehouseSecret -VaultKey 'GUERRILLA_GWS_SA' -VaultName $vaultName
            if ($saJson) {
                $tempSaPath = Join-Path ([System.IO.Path]::GetTempPath()) "guerrilla-sa-$([guid]::NewGuid().ToString('N').Substring(0,8)).json"
                $saJson | Set-Content -Path $tempSaPath -Encoding UTF8
                $ServiceAccountKeyPath = $tempSaPath
            }
        }
        if (-not $AdminEmail) {
            $AdminEmail = Get-SafehouseSecret -VaultKey 'GUERRILLA_GWS_SA_ADMIN_EMAIL' -VaultName $vaultName
        }
        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 }
        }

        # --- Auto-detect platforms from provided credentials ---
        if (-not $Platforms) {
            if ($TestMode) {
                # Test mode needs no credentials — simulate the full big report.
                $Platforms = @('Workspace', 'AD', 'Cloud')
            }
            else {
                $Platforms = @()
                if ($ServiceAccountKeyPath -and $AdminEmail) { $Platforms += 'Workspace' }
                if ($Server -or $Credential -or (Get-Command Get-ADDomain -ErrorAction SilentlyContinue)) {
                    $Platforms += 'AD'
                }
                if ($TenantId -and $ClientId) { $Platforms += 'Cloud' }

                if ($Platforms.Count -eq 0) {
                    throw 'No platforms could be determined. Provide -Platforms or supply credentials for at least one platform.'
                }
            }
        }

        # Test mode renders zeroed timestamps for deterministic demo/sample output.
        $script:GuerrillaTestMode = [bool]$TestMode

        # --- Operation header ---
        if (-not $Quiet) {
            $platformLabel = $Platforms -join ' + '
            Write-OperationHeader -Operation 'CAMPAIGN AUDIT' -Mode $platformLabel -Target 'Full Spectrum' -DaysBack 0
        }

        # --- Run each platform ---
        $platformResults = @{}
        $allFindings = [System.Collections.Generic.List[PSCustomObject]]::new()

        # ── Workspace Platform ──────────────────────────────────────────────
        if ('Workspace' -in $Platforms) {
            if (-not $TestMode -and (-not $ServiceAccountKeyPath -or -not $AdminEmail)) {
                throw 'Workspace platform requires -ServiceAccountKeyPath and -AdminEmail'
            }

            if (-not $Quiet) {
                Write-ProgressLine -Phase CAMPAIGN -Message 'Launching Google Workspace audit'
            }

            $fortParams = @{
                ServiceAccountKeyPath = $ServiceAccountKeyPath
                AdminEmail            = $AdminEmail
                NoReports             = $true
                NoDelta               = $NoDelta.IsPresent
                Quiet                 = $Quiet.IsPresent
            }
            if ($ConfigPath) { $fortParams['ConfigPath'] = $ConfigPath }
            if ($ConfigFile) { $fortParams['ConfigFile'] = $ConfigFile }
            if ($TargetOU) { $fortParams['TargetOU'] = $TargetOU }
            if ($TestMode) { $fortParams['TestMode'] = $true }

            try {
                $fortResult = Invoke-GWSAudit @fortParams
                $platformResults['Google Workspace'] = $fortResult

                foreach ($f in @($fortResult.Findings)) {
                    $f | Add-Member -NotePropertyName 'Platform' -NotePropertyValue 'Google Workspace' -Force
                    $allFindings.Add($f)
                }

                if (-not $Quiet) {
                    Write-ProgressLine -Phase CAMPAIGN -Message "Workspace: $($fortResult.Findings.Count) checks, score $($fortResult.OverallScore)/100"
                }
            } catch {
                Write-Warning "Workspace platform failed: $_"
                $platformResults['Google Workspace'] = @{ Error = $_.Exception.Message }
            }
        }

        # ── AD Platform ─────────────────────────────────────────────────────
        if ('AD' -in $Platforms) {
            if (-not $Quiet) {
                Write-ProgressLine -Phase CAMPAIGN -Message 'Launching Active Directory audit'
            }

            $reconParams = @{
                NoReports = $true
                NoDelta   = $NoDelta.IsPresent
                Quiet     = $Quiet.IsPresent
            }
            if ($Server) { $reconParams['Server'] = $Server }
            if ($Credential) { $reconParams['Credential'] = $Credential }
            if ($ConfigPath) { $reconParams['ConfigPath'] = $ConfigPath }
            if ($ConfigFile) { $reconParams['ConfigFile'] = $ConfigFile }
            if ($TestMode) { $reconParams['TestMode'] = $true }

            try {
                $reconResult = Invoke-ADAudit @reconParams
                $platformResults['Active Directory'] = $reconResult

                foreach ($f in @($reconResult.Findings)) {
                    $f | Add-Member -NotePropertyName 'Platform' -NotePropertyValue 'Active Directory' -Force
                    $allFindings.Add($f)
                }

                if (-not $Quiet) {
                    $domainLabel = $reconResult.DomainName ?? 'Current Domain'
                    Write-ProgressLine -Phase CAMPAIGN -Message "AD ($domainLabel): $($reconResult.Findings.Count) checks, score $($reconResult.OverallScore)/100"
                }
            } catch {
                Write-Warning "AD platform failed: $_"
                $platformResults['Active Directory'] = @{ Error = $_.Exception.Message }
            }
        }

        # ── Cloud Platform ──────────────────────────────────────────────────
        if ('Cloud' -in $Platforms) {
            if (-not $TestMode -and (-not $TenantId -or -not $ClientId)) {
                throw 'Cloud platform requires -TenantId and -ClientId'
            }

            if (-not $Quiet) {
                Write-ProgressLine -Phase CAMPAIGN -Message 'Launching Entra ID / M365 audit'
            }

            $infilParams = @{
                TenantId  = $TenantId
                ClientId  = $ClientId
                NoReports = $true
                NoDelta   = $NoDelta.IsPresent
                Quiet     = $Quiet.IsPresent
            }
            if ($CertificateThumbprint) { $infilParams['CertificateThumbprint'] = $CertificateThumbprint }
            if ($ClientSecret) { $infilParams['ClientSecret'] = $ClientSecret }
            if ($DeviceCode) { $infilParams['DeviceCode'] = $true }
            if ($ConfigPath) { $infilParams['ConfigPath'] = $ConfigPath }
            if ($ConfigFile) { $infilParams['ConfigFile'] = $ConfigFile }
            if ($TestMode) { $infilParams['TestMode'] = $true }

            try {
                $infilResult = Invoke-EntraAudit @infilParams
                $platformResults['Microsoft Cloud'] = $infilResult

                foreach ($f in @($infilResult.Findings)) {
                    $f | Add-Member -NotePropertyName 'Platform' -NotePropertyValue 'Microsoft Cloud' -Force
                    $allFindings.Add($f)
                }

                if (-not $Quiet) {
                    Write-ProgressLine -Phase CAMPAIGN -Message "Cloud ($TenantId): $($infilResult.Findings.Count) checks, score $($infilResult.Score.OverallScore)/100"
                }
            } catch {
                Write-Warning "Cloud platform failed: $_"
                $platformResults['Microsoft Cloud'] = @{ Error = $_.Exception.Message }
            }
        }

        # --- Unified scoring ---
        if (-not $Quiet) {
            Write-ProgressLine -Phase CAMPAIGN -Message 'Calculating unified posture score'
        }

        $unifiedScore = Get-AuditPostureScore -Findings @($allFindings)
        $overallScore = $unifiedScore.OverallScore
        $scoreLabel = Get-AuditScoreLabel -Score $overallScore

        # --- Build per-platform score summary ---
        $platformScores = @{}
        foreach ($platformName in $platformResults.Keys) {
            $platformFindings = @($allFindings | Where-Object Platform -eq $platformName)
            if ($platformFindings.Count -gt 0) {
                $ts = Get-AuditPostureScore -Findings $platformFindings
                $platformScores[$platformName] = @{
                    Score        = $ts.OverallScore
                    ScoreLabel   = Get-AuditScoreLabel -Score $ts.OverallScore
                    FindingCount = $platformFindings.Count
                    PassCount    = @($platformFindings | Where-Object Status -eq 'PASS').Count
                    FailCount    = @($platformFindings | Where-Object Status -eq 'FAIL').Count
                    WarnCount    = @($platformFindings | Where-Object Status -eq 'WARN').Count
                    SkipCount    = @($platformFindings | Where-Object Status -in @('SKIP', 'ERROR')).Count
                    CategoryScores = $ts.CategoryScores
                }
            }
        }

        # --- Run-over-run comparison against the local run history ---
        # The campaign records its own combined series (platform set = all
        # platforms run) alongside the per-platform series the sub-audits keep.
        $runRecord = $null
        $runDiff = $null
        if (-not $NoDelta -and -not $TestMode) {
            if (-not $Quiet) { Write-ProgressLine -Phase CAMPAIGN -Message 'Comparing against previous run' }
            try {
                $platformKeyMap = @{ 'Active Directory' = 'AD'; 'Microsoft Cloud' = 'Entra'; 'Google Workspace' = 'GWS' }
                $recordPlatforms = @()
                $targetIds = @()
                foreach ($key in $platformResults.Keys) {
                    $sub = $platformResults[$key]
                    if ($sub -is [hashtable] -and $sub.Error) { continue }
                    $recordPlatforms += ($platformKeyMap[$key] ?? $key)
                    $id = $sub.DomainName ?? $sub.TenantId ?? $sub.TenantDomain
                    if ($id) { $targetIds += "$id" }
                }
                if ($recordPlatforms.Count -gt 0 -and $targetIds.Count -gt 0) {
                    $runRecord = New-GuerrillaRunRecord -Findings @($allFindings) -Platforms $recordPlatforms `
                        -TargetId $targetIds -ScanId $scanId -OverallScore $overallScore
                    $previousRun = Get-GuerrillaPreviousRun -Platforms $recordPlatforms -TargetHash $runRecord.scope.targetHash
                    $runDiff = Compare-GuerrillaRun -Previous $previousRun -Current $runRecord
                }
            } catch {
                Write-Warning "Run comparison unavailable: $_"
            }
        }

        # --- Console report ---
        if (-not $Quiet) {
            Write-CampaignReport `
                -OverallScore $overallScore `
                -ScoreLabel $scoreLabel `
                -PlatformScores $platformScores `
                -CategoryScores $unifiedScore.CategoryScores `
                -Findings @($allFindings)
        }

        # --- Generate reports ---
        $scanEnd = [datetime]::UtcNow
        $scanDuration = $scanEnd - $scanStart

        $result = [PSCustomObject]@{
            PSTypeName     = 'Guerrilla.CampaignResult'
            ScanId         = $scanId
            ScanStart      = $scanStart
            ScanEnd        = $scanEnd
            Duration       = $scanDuration
            Platforms       = $Platforms
            OverallScore   = $overallScore
            ScoreLabel     = $scoreLabel
            PlatformScores  = $platformScores
            CategoryScores = $unifiedScore.CategoryScores
            Findings       = @($allFindings)
            RunComparison  = $runDiff
            PlatformResults = $platformResults
        }

        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') }
        $baseName = "campaign-$timestamp"

        if (-not $Quiet) {
            Write-ProgressLine -Phase CAMPAIGN -Message 'Generating unified 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
            }
            $htmlPath = Join-Path $outDir "$baseName.html"
            Export-CampaignReportHtml -Result $result -OutputPath $htmlPath `
                -Style $ReportStyle -Branding (Get-GuerrillaBranding -Config $config)
            $result | Add-Member -NotePropertyName 'HtmlReportPath' -NotePropertyValue $htmlPath
            if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'HTML report' -Detail $htmlPath }
        } catch {
            Write-Warning "HTML report generation failed: $_"
        }

        try {
            $csvPath = Join-Path $outDir "$baseName.csv"
            Export-CampaignReportCsv -Result $result -OutputPath $csvPath
            $result | Add-Member -NotePropertyName 'CsvReportPath' -NotePropertyValue $csvPath
            if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'CSV report' -Detail $csvPath }
        } catch {
            Write-Warning "CSV report generation failed: $_"
        }

        try {
            $jsonPath = Join-Path $outDir "$baseName.json"
            Export-CampaignReportJson -Result $result -OutputPath $jsonPath
            $result | Add-Member -NotePropertyName 'JsonReportPath' -NotePropertyValue $jsonPath
            if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'JSON report' -Detail $jsonPath }
        } catch {
            Write-Warning "JSON report generation failed: $_"
        }

        # --- 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: $_"
            }
        }

        if (-not $Quiet) {
            Write-ProgressLine -Phase CAMPAIGN -Message "Campaign complete in $([Math]::Round($scanDuration.TotalSeconds, 1))s — $($allFindings.Count) checks across $($Platforms.Count) platform(s)"
        }

        return $result
    } finally {
        if ($tempSaPath -and (Test-Path $tempSaPath)) {
            Remove-Item -Path $tempSaPath -Force -ErrorAction SilentlyContinue
        }
    }
}