Private/Audit/Invoke-GoogleTradecraftChecks.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-GoogleTradecraftChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData,

        [string]$OrgUnitPath = '/'
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'GoogleTradecraftChecks'
    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($check in $checkDefs.checks) {
        $funcName = "Test-Fortification$($check.id -replace '-', '')"
        if (Get-Command $funcName -ErrorAction SilentlyContinue) {
            try {
                $finding = & $funcName -AuditData $AuditData -CheckDefinition $check -OrgUnitPath $OrgUnitPath
                if ($finding) { $findings.Add($finding) }
            } catch {
                $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' `
                    -CurrentValue "Check failed: $_" -OrgUnitPath $OrgUnitPath))
            }
        } else {
            $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' `
                -CurrentValue 'Check not yet implemented' -OrgUnitPath $OrgUnitPath))
        }
    }

    return @($findings)
}

# Returns the non-metadata group-settings entries from $AuditData.GroupSettings, or $null if
# the collector didn't run (so checks SKIP rather than report a false clean).
function Get-TradecraftGroupSettings {
    param([hashtable]$AuditData)
    $gs = $AuditData.GroupSettings
    if (-not $gs -or $gs.Count -eq 0) { return $null }
    $entries = @()
    foreach ($k in $gs.Keys) {
        if ($k -eq '__truncated') { continue }
        $entries += $gs[$k]
    }
    if ($entries.Count -eq 0) { return $null }
    return @($entries)
}

# ── GTRADE-001: Domain-Wide Delegation org-takeover exposure (DeleFriend precondition) ──
function Test-FortificationGTRADE001 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition, [string]$OrgUnitPath = '/')

    # NOTE: there is no reliable GA Directory API to LIST domain-wide-delegation grants (the
    # legacy /domainwidedelegation path 404s on many tenants; DeleFriend itself enumerates grants
    # by brute-forcing client-id/scope pairs). So an empty collection means "could not enumerate",
    # NOT "no grants" — never report PASS on emptiness, or the check gives a false all-clear on the
    # exact attack surface it exists for.
    $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors `
        -SourceKey 'DomainWideDelegation' -Subject 'domain-wide delegation grants'
    if ($na) { return $na }

    # Filter $null (a missing key makes @($null).Count == 1, which would slip past an empty check).
    $grants = @($AuditData.DomainWideDelegation | Where-Object { $null -ne $_ })
    if ($grants.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'Domain-wide delegation grants could not be enumerated via the Directory API (no GA list endpoint) — this is NOT a confirmation that none exist' `
            -OrgUnitPath $OrgUnitPath `
            -Details @{ Note = 'Verify manually: Admin Console > Security > API controls > Domain-wide delegation. Treat any grant holding full mail.google.com / drive / admin.directory / cloud-platform scopes as a DeleFriend takeover precondition — a new key on that service account grants org-wide impersonation.' }
    }

    # High-risk = scopes that let a delegated SA impersonate org-wide (the DeleFriend impact).
    $isHighRisk = {
        param([string]$s)
        $s = $s.ToLower()
        if ($s -match 'mail\.google\.com') { return $true }
        if ($s -match 'gmail\.(modify|settings|compose|insert)') { return $true }
        if ($s -match 'auth/drive($|[^.])' -and $s -notmatch 'drive\.(readonly|file|metadata|appdata|photos)') { return $true }
        if ($s -match 'admin\.directory' -and $s -notmatch '\.readonly') { return $true }
        if ($s -match 'cloud-platform') { return $true }
        if ($s -match 'auth/apps\.groups($|[^.])') { return $true }
        return $false
    }

    $risky = [System.Collections.Generic.List[string]]::new()
    foreach ($grant in $grants) {
        $clientId = $grant.clientId ?? $grant.ClientId ?? 'Unknown'
        $scopes = @($grant.scopes ?? $grant.Scopes ?? @())
        $hits = @($scopes | Where-Object { & $isHighRisk "$_" })
        if ($hits.Count -gt 0) {
            $risky.Add("$clientId (org-impersonation scope: $((@($hits) | Select-Object -First 2) -join ', '))")
        }
    }

    if ($risky.Count -gt 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
            -CurrentValue "$($risky.Count) of $($grants.Count) domain-wide delegation grant(s) hold org-impersonation scopes — each is a DeleFriend takeover target if its service account gets a new key" `
            -OrgUnitPath $OrgUnitPath `
            -Details @{
                RiskyGrants   = @($risky)
                AffectedItems = @($risky)
                AffectedLabel = 'Domain-wide delegation grants with org-impersonation scopes'
                Note          = 'Full DeleFriend confirmation (a user-managed key on the delegated service account) requires GCP IAM access — not yet collected. Treat any broad-scope grant as a takeover precondition.'
            }
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($grants.Count) domain-wide delegation grant(s); none hold full mail/drive/directory/cloud-platform impersonation scopes" `
        -OrgUnitPath $OrgUnitPath
}

# ── GTRADE-002: Internet-readable Google Groups ──────────────────────────────
function Test-FortificationGTRADE002 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition, [string]$OrgUnitPath = '/')

    $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors `
        -SourceKey 'GroupSettings' -Subject 'group settings'
    if ($na) { return $na }

    $entries = Get-TradecraftGroupSettings -AuditData $AuditData
    if ($null -eq $entries) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Group settings not collected (run without -Quick, and ensure the apps.groups.settings scope is delegated)' `
            -OrgUnitPath $OrgUnitPath
    }
    $public = @($entries | Where-Object { "$($_.whoCanViewGroup)" -match '(?i)ANYONE_CAN_VIEW' })
    if ($public.Count -gt 0) {
        $emails = @($public | ForEach-Object { $_.email })
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
            -CurrentValue "$($public.Count) of $($entries.Count) group(s) are viewable by anyone on the internet" `
            -OrgUnitPath $OrgUnitPath `
            -Details @{ AffectedItems = $emails; AffectedLabel = 'Internet-readable groups' }
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "No internet-readable groups ($($entries.Count) inspected)" -OrgUnitPath $OrgUnitPath
}

# ── GTRADE-003: Open-join / external-member groups ───────────────────────────
function Test-FortificationGTRADE003 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition, [string]$OrgUnitPath = '/')

    $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors `
        -SourceKey 'GroupSettings' -Subject 'group settings'
    if ($na) { return $na }

    $entries = Get-TradecraftGroupSettings -AuditData $AuditData
    if ($null -eq $entries) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Group settings not collected (run without -Quick, and ensure the apps.groups.settings scope is delegated)' `
            -OrgUnitPath $OrgUnitPath
    }
    $open = @($entries | Where-Object {
        "$($_.whoCanJoin)" -match '(?i)ANYONE_CAN_JOIN|ALL_IN_DOMAIN_CAN_JOIN' -or
        "$($_.allowExternalMembers)" -match '(?i)^true$'
    })
    if ($open.Count -gt 0) {
        $emails = @($open | ForEach-Object { $_.email })
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue "$($open.Count) of $($entries.Count) group(s) allow open join or external members" `
            -OrgUnitPath $OrgUnitPath `
            -Details @{
                AffectedItems = $emails
                AffectedLabel = 'Open-join / external-member groups'
                Note          = 'If any such group holds resource or IAM access, joining it inherits that access (a privilege-escalation path Google treats as intended behavior).'
            }
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "No open-join or external-member groups ($($entries.Count) inspected)" -OrgUnitPath $OrgUnitPath
}

# ── GTRADE-004: Super-admin sprawl ───────────────────────────────────────────
function Test-FortificationGTRADE004 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition, [string]$OrgUnitPath = '/')

    $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors `
        -SourceKey 'Users' -Subject 'user inventory'
    if ($na) { return $na }

    if (-not $AuditData.Users -or @($AuditData.Users).Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'No user data available' -OrgUnitPath $OrgUnitPath
    }
    $supers = @($AuditData.Users | Where-Object { $_.isAdmin -eq $true -and -not $_.suspended })
    $n = $supers.Count
    $status = if ($n -le 4) { 'PASS' } elseif ($n -le 10) { 'WARN' } else { 'FAIL' }
    $details = @{ SuperAdminCount = $n }
    if ($n -gt 4) {
        $details.AffectedItems = @($supers | ForEach-Object { $_.primaryEmail })
        $details.AffectedLabel = 'Super administrators'
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$n active super administrator(s) (best practice: fewer than 5)" `
        -OrgUnitPath $OrgUnitPath -Details $details
}

# ── GTRADE-005: Super-admin-equivalent custom roles ──────────────────────────
function Test-FortificationGTRADE005 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition, [string]$OrgUnitPath = '/')

    $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors `
        -SourceKey 'Roles' -Subject 'admin roles'
    if ($na) { return $na }

    if (-not $AuditData.Roles -or @($AuditData.Roles).Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'No role data available' -OrgUnitPath $OrgUnitPath
    }
    $custom = @($AuditData.Roles | Where-Object { $_.isSystemRole -ne $true -and $_.isSuperAdminRole -ne $true })
    if ($custom.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No custom admin roles defined' -OrgUnitPath $OrgUnitPath
    }

    # Real Google admin privilege vocabulary for super-admin-equivalent power. Match WRITE/MANAGE
    # privileges (and the catch-all _ALL), and explicitly EXCLUDE read-only (_RETRIEVE) — a
    # directory-reader role (e.g. USERS_RETRIEVE / GROUPS_RETRIEVE) is NOT super-admin-equivalent.
    $writePriv = '(?i)(_ALL$|_CREATE$|_DELETE$|_UPDATE$|_SUSPEND$|_MOVE$|RESET_PASSWORD|FORCE_PASSWORD_CHANGE|DOMAIN_MANAGEMENT|APP_ADMIN|ROLE_MANAGEMENT|MANAGE_|SECURITY)'

    $flagged = [System.Collections.Generic.List[string]]::new()
    foreach ($role in $custom) {
        $privs = @($role.rolePrivileges ?? $role.RolePrivileges ?? @())
        $names = @($privs | ForEach-Object { "$($_.privilegeName ?? $_.PrivilegeName)" })
        $hits = @($names | Where-Object { $_ -match $writePriv -and $_ -notmatch '(?i)_RETRIEVE$' } | Select-Object -Unique)
        if ($hits.Count -gt 0) {
            $roleName = $role.roleName ?? $role.name ?? 'Unknown'
            $flagged.Add("$roleName ($((@($hits) | Select-Object -First 3) -join ', '))")
        }
    }

    if ($flagged.Count -gt 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue "$($flagged.Count) of $($custom.Count) custom role(s) carry super-admin-equivalent privileges" `
            -OrgUnitPath $OrgUnitPath `
            -Details @{ AffectedItems = @($flagged); AffectedLabel = 'Custom roles with high-power privileges' }
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "No custom role carries super-admin-equivalent privileges ($($custom.Count) custom role(s) reviewed)" `
        -OrgUnitPath $OrgUnitPath
}

# ── GTRADE-006: Persistent / over-scoped OAuth grants (GhostToken-class) ──────
function Test-FortificationGTRADE006 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition, [string]$OrgUnitPath = '/')

    $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition -ErrorMap $AuditData.Errors `
        -SourceKey 'OAuthApps' -Subject 'OAuth token activity'
    if ($na) { return $na }

    if (-not $AuditData.OAuthApps -or @($AuditData.OAuthApps).Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'OAuth token activity not available (collect from Reports API to enumerate third-party grants)' `
            -OrgUnitPath $OrgUnitPath
    }

    # Aggregate scopes per app across token events.
    $apps = @{}
    foreach ($event in $AuditData.OAuthApps) {
        # Prefer the friendly app name; where Google has none, label the client ID clearly
        # (rather than surfacing a bare numeric/platform string) so the finding stays actionable.
        $name = if ($event.Params.app_name) { "$($event.Params.app_name)" }
                elseif ($event.Params.client_id) { "unnamed app ($($event.Params.client_id))" }
                else { $null }
        if (-not $name) { continue }
        $scope = "$($event.Params.scope)"
        if (-not $apps.ContainsKey($name)) { $apps[$name] = [System.Collections.Generic.HashSet[string]]::new() }
        foreach ($s in ($scope -split '\s+')) { if ($s) { [void]$apps[$name].Add($s.ToLower()) } }
    }

    $isHighRisk = {
        param([string]$s)
        ($s -match 'mail\.google\.com') -or
        ($s -match 'auth/drive($|[^.])' -and $s -notmatch 'drive\.(readonly|file|metadata|appdata|photos)') -or
        ($s -match 'admin\.directory' -and $s -notmatch '\.readonly') -or
        ($s -match 'cloud-platform')
    }

    $risky = [System.Collections.Generic.List[string]]::new()
    foreach ($name in $apps.Keys) {
        $hit = @($apps[$name] | Where-Object { & $isHighRisk $_ })
        if ($hit.Count -gt 0) { $risky.Add("$name") }
    }

    if ($risky.Count -gt 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
            -CurrentValue "$($risky.Count) of $($apps.Count) third-party OAuth app(s) hold full mail/drive/admin scopes (persist across password reset)" `
            -OrgUnitPath $OrgUnitPath `
            -Details @{
                AffectedItems = @($risky)
                AffectedLabel = 'Over-scoped OAuth grants'
                Note          = 'These grants bypass MFA and survive a password reset (Apps Script / app passwords / IMAP-OAuth are not revoked by a reset) — revoke the tokens explicitly.'
            }
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "No third-party OAuth app holds full mail/drive/admin scopes ($($apps.Count) app(s) reviewed)" `
        -OrgUnitPath $OrgUnitPath
}