Public/Invoke-Campaign.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | 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 existing
        Fortification (Google Workspace), Reconnaissance (Active Directory), and Infiltration
        (Entra ID / Azure / Intune / M365) audits and combining their findings into one unified report.
 
        Each theater is optional — skip what doesn't apply to your org.
 
    .PARAMETER Theaters
        Which theaters to audit. Default: auto-detect from provided credentials.
        Valid values: Workspace, AD, Cloud
 
    .PARAMETER ServiceAccountKeyPath
        Google service account key JSON path (enables Workspace theater).
 
    .PARAMETER AdminEmail
        Google Workspace admin email (enables Workspace theater).
 
    .PARAMETER Server
        AD domain controller (enables AD theater). Omit to use current domain.
 
    .PARAMETER Credential
        AD credentials. Omit to use current user.
 
    .PARAMETER TenantId
        Entra ID tenant ID (enables Cloud theater).
 
    .PARAMETER ClientId
        Entra app registration client ID (enables Cloud theater).
 
    .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 + /PSGuerrilla/Reports
        (Windows: $env:APPDATA; macOS: ~/Library/Application Support; Linux: $XDG_CONFIG_HOME or ~/.config)
 
    .PARAMETER NoDelta
        Skip delta comparison with previous scan.
 
    .PARAMETER Quiet
        Suppress console output.
 
    .PARAMETER ConfigPath
        Path to PSGuerrilla configuration file.
 
    .EXAMPLE
        Invoke-Campaign -Theaters Cloud -TenantId $t -ClientId $c -DeviceCode
 
    .EXAMPLE
        Invoke-Campaign -Theaters AD, Cloud -TenantId $t -ClientId $c -ClientSecret $s
 
    .EXAMPLE
        Invoke-Campaign -ServiceAccountKeyPath $key -AdminEmail $admin -TenantId $t -ClientId $c -DeviceCode
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('Workspace', 'AD', 'Cloud')]
        [string[]]$Theaters,

        # ── 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
    )

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

        # Determine theaters from enabled environments
        if (-not $PSBoundParameters.ContainsKey('Theaters')) {
            $Theaters = @()
            if ($missionCfg.EnabledEnvironments.ContainsKey('googleWorkspace')) { $Theaters += 'Workspace' }
            if ($missionCfg.EnabledEnvironments.ContainsKey('activeDirectory')) { $Theaters += 'AD' }
            if ($missionCfg.EnabledEnvironments.ContainsKey('entraAzure') -or
                $missionCfg.EnabledEnvironments.ContainsKey('m365') -or
                $missionCfg.EnabledEnvironments.ContainsKey('intune')) { $Theaters += '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 {}
        }
    }

    $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-PSGuerrillaDataRoot) 'Reports' }

    # --- Auto-detect theaters from provided credentials ---
    if (-not $Theaters) {
        $Theaters = @()
        if ($ServiceAccountKeyPath -and $AdminEmail) { $Theaters += 'Workspace' }
        if ($Server -or $Credential -or (Get-Command Get-ADDomain -ErrorAction SilentlyContinue)) {
            $Theaters += 'AD'
        }
        if ($TenantId -and $ClientId) { $Theaters += 'Cloud' }

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

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

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

    # ── Workspace Theater ──────────────────────────────────────────────
    if ('Workspace' -in $Theaters) {
        if (-not $ServiceAccountKeyPath -or -not $AdminEmail) {
            throw 'Workspace theater requires -ServiceAccountKeyPath and -AdminEmail'
        }

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

        $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 }

        try {
            $fortResult = Invoke-Fortification @fortParams
            $theaterResults['Google Workspace'] = $fortResult

            foreach ($f in @($fortResult.Findings)) {
                $f | Add-Member -NotePropertyName 'Theater' -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 theater failed: $_"
            $theaterResults['Google Workspace'] = @{ Error = $_.Exception.Message }
        }
    }

    # ── AD Theater ─────────────────────────────────────────────────────
    if ('AD' -in $Theaters) {
        if (-not $Quiet) {
            Write-ProgressLine -Phase CAMPAIGN -Message 'Launching AD theater (Reconnaissance)'
        }

        $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 }

        try {
            $reconResult = Invoke-Reconnaissance @reconParams
            $theaterResults['Active Directory'] = $reconResult

            foreach ($f in @($reconResult.Findings)) {
                $f | Add-Member -NotePropertyName 'Theater' -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 theater failed: $_"
            $theaterResults['Active Directory'] = @{ Error = $_.Exception.Message }
        }
    }

    # ── Cloud Theater ──────────────────────────────────────────────────
    if ('Cloud' -in $Theaters) {
        if (-not $TenantId -or -not $ClientId) {
            throw 'Cloud theater requires -TenantId and -ClientId'
        }

        if (-not $Quiet) {
            Write-ProgressLine -Phase CAMPAIGN -Message 'Launching Cloud theater (Infiltration)'
        }

        $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 }

        try {
            $infilResult = Invoke-Infiltration @infilParams
            $theaterResults['Microsoft Cloud'] = $infilResult

            foreach ($f in @($infilResult.Findings)) {
                $f | Add-Member -NotePropertyName 'Theater' -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 theater failed: $_"
            $theaterResults['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-FortificationScoreLabel -Score $overallScore

    # --- Build per-theater score summary ---
    $theaterScores = @{}
    foreach ($theaterName in $theaterResults.Keys) {
        $theaterFindings = @($allFindings | Where-Object Theater -eq $theaterName)
        if ($theaterFindings.Count -gt 0) {
            $ts = Get-AuditPostureScore -Findings $theaterFindings
            $theaterScores[$theaterName] = @{
                Score        = $ts.OverallScore
                ScoreLabel   = Get-FortificationScoreLabel -Score $ts.OverallScore
                FindingCount = $theaterFindings.Count
                PassCount    = @($theaterFindings | Where-Object Status -eq 'PASS').Count
                FailCount    = @($theaterFindings | Where-Object Status -eq 'FAIL').Count
                WarnCount    = @($theaterFindings | Where-Object Status -eq 'WARN').Count
                SkipCount    = @($theaterFindings | Where-Object Status -in @('SKIP', 'ERROR')).Count
                CategoryScores = $ts.CategoryScores
            }
        }
    }

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

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

    $result = [PSCustomObject]@{
        PSTypeName     = 'PSGuerrilla.CampaignResult'
        ScanId         = $scanId
        ScanStart      = $scanStart
        ScanEnd        = $scanEnd
        Duration       = $scanDuration
        Theaters       = $Theaters
        OverallScore   = $overallScore
        ScoreLabel     = $scoreLabel
        TheaterScores  = $theaterScores
        CategoryScores = $unifiedScore.CategoryScores
        Findings       = @($allFindings)
        TheaterResults = $theaterResults
    }

    if (-not (Test-Path $outDir)) {
        New-Item -Path $outDir -ItemType Directory -Force | Out-Null
    }

    $timestamp = $scanStart.ToString('yyyyMMdd-HHmmss')
    $baseName = "campaign-$timestamp"

    if (-not $Quiet) {
        Write-ProgressLine -Phase CAMPAIGN -Message 'Generating unified reports'
    }

    try {
        $htmlPath = Join-Path $outDir "$baseName.html"
        Export-CampaignReportHtml -Result $result -OutputPath $htmlPath
        $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: $_"
    }

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

    return $result
}