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 = '/') if (-not $AuditData.ContainsKey('DomainWideDelegation') -or $null -eq $AuditData.DomainWideDelegation) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Domain-wide delegation data not available' -OrgUnitPath $OrgUnitPath } $grants = @($AuditData.DomainWideDelegation) if ($grants.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No domain-wide delegation grants configured' -OrgUnitPath $OrgUnitPath } # 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 = '/') $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 = '/') $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 = '/') 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 = '/') 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 } # Privilege names that approximate super-admin capability. $sensitive = 'SUPER_ADMIN|ROOT|SECURITY|MANAGE_USER|USER_CREATION|USER_UPDATE|DELETE_USER|RENAME_USER|MOVE_USER|ROLE_MANAGEMENT|MANAGE_ROLES|MANAGE_DELEGATED_ADMIN|DATA_TRANSFER|TAKEOUT|ORGANIZATION_UNITS' $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 "(?i)$sensitive" } | 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 = '/') 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) { $name = $event.Params.app_name ?? $event.Params.client_id 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 } |