public/Invoke-GWSAudit.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-GWSAudit { <# .SYNOPSIS Performs a comprehensive Google Workspace security assessment. .DESCRIPTION Invoke-GWSAudit executes a read-only audit of Google Workspace configuration: authentication and 2SV enforcement, Gmail security, Drive sharing, OAuth app governance, admin-role hygiene, collaboration settings, device management, logging and alerting, and per-service settings aligned to the CISA SCuBA (ScubaGoggles) baselines. Authentication uses a service account with domain-wide delegation. On completion the run is recorded in the local run history and compared against the previous run for the same tenant, so the report opens with what changed. .PARAMETER ServiceAccountKeyPath Path to the service-account JSON key with domain-wide delegation. .PARAMETER AdminEmail Workspace admin email the service account impersonates. .PARAMETER Categories Specifies which audit categories to run. Default is 'All'. Valid values: All, Authentication, EmailSecurity, DriveSecurity, OAuthSecurity, AdminManagement, Collaboration, DeviceManagement, LoggingAlerting, GwsService .PARAMETER IncludeChildOUs Also audit organizational units beneath TargetOU. .PARAMETER TargetOU Organizational unit path to audit. Default '/' (the whole tenant). .PARAMETER StudentOU Organizational unit path(s) that contain student accounts, e.g. '/Students' or '/Students/HighSchool'. This is a designation, not a collection filter: collection stays tenant-wide (so staff and student posture can be compared), but the K12 checks that assess student posture evaluate these OU subtrees specifically. Distinct from TargetOU, which narrows what is collected. When omitted, OU-scoped K12 checks report Not Assessed rather than assessing the whole tenant as if it were the student population. The OU scope is part of the run's comparison identity: a student-scoped run is never diffed against a whole-tenant run. .PARAMETER UserSampleSize Maximum users sampled for per-user checks. Default 500. .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 the service-account key from the SecretManagement vault and applies category filtering from the mission config. .PARAMETER VaultName SecretManagement vault name for credential resolution. Default 'Guerrilla'. .PARAMETER ReportStyle Initial theme for the HTML report: Auto (default; follows the viewer's OS setting, with an in-report light/dark toggle), Light, or Dark. Legacy values Guerrilla, Professional, and Slate remain accepted (Professional maps to Light; Guerrilla and Slate map to Dark). .PARAMETER TestMode Run against fixture data instead of a live tenant. .PARAMETER Quick Skip the slow per-user Gmail-settings crawl (directory, DNS, and OAuth checks still run; Gmail-dependent EMAIL checks report Not Assessed). .EXAMPLE Invoke-GWSAudit -ServiceAccountKeyPath ./sa-key.json -AdminEmail admin@contoso.com .EXAMPLE Invoke-GWSAudit -ServiceAccountKeyPath ./sa-key.json -AdminEmail admin@contoso.com -TargetOU '/Engineering' -IncludeChildOUs .EXAMPLE Invoke-GWSAudit -ConfigFile ./guerrilla-config.json -Quick #> [CmdletBinding()] param( [string]$ServiceAccountKeyPath, [string]$AdminEmail, [ValidateSet('All', 'Authentication', 'EmailSecurity', 'DriveSecurity', 'OAuthSecurity', 'AdminManagement', 'Collaboration', 'DeviceManagement', 'LoggingAlerting', 'GwsService', 'K12')] [string[]]$Categories = @('All'), [switch]$IncludeChildOUs, [string]$TargetOU = '/', [string[]]$StudentOU = @(), [int]$UserSampleSize = 500, [string]$OutputDirectory, [switch]$NoReports, [switch]$NoDelta, [switch]$Quiet, [Alias('RuntimeConfig')] [string]$ConfigPath, [Alias('MissionConfig')] [string]$ConfigFile, [string]$VaultName = 'Guerrilla', [ValidateSet('Auto', 'Light', 'Dark', 'Guerrilla', 'Professional', 'Slate')] [string]$ReportStyle = 'Auto', [switch]$TestMode, # Skip the slow per-user Gmail-settings crawl (directory + DNS + OAuth still run; # the Gmail-dependent EMAIL checks SKIP). Fast iteration on large tenants. [switch]$Quick ) $tempSaPath = $null $vaultName = $VaultName # --- Resolve mission config (guerrilla-config.json) --- if ($ConfigFile) { $missionCfg = Read-MissionConfig -Path $ConfigFile $vaultName = $missionCfg.VaultName # 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-Warning "Failed to resolve GWS service account from vault: $_" } } if (-not $PSBoundParameters.ContainsKey('AdminEmail')) { try { $AdminEmail = Get-GuerrillaCredential -VaultKey "$($gwsRef.vaultKey)_ADMIN_EMAIL" -VaultName $vaultName } catch { Write-Verbose "AdminEmail not found in vault — will fall back to config.json or parameter." } } } # Apply targetOU from mission config if (-not $PSBoundParameters.ContainsKey('TargetOU')) { $gwsAudit = $missionCfg.EnabledEnvironments['googleWorkspace'] if ($gwsAudit -and $gwsAudit.audit -and $gwsAudit.audit.targetOU) { $TargetOU = $gwsAudit.audit.targetOU } } # Apply categories from mission config if (-not $PSBoundParameters.ContainsKey('Categories')) { $gwsEnv = $missionCfg.EnabledEnvironments['googleWorkspace'] if ($gwsEnv -and $gwsEnv.audit -and $gwsEnv.audit.categories) { $missionCats = @($gwsEnv.audit.categories.GetEnumerator() | Where-Object { $_.Value } | ForEach-Object { $_.Key }) if ($missionCats.Count -gt 0) { $Categories = $missionCats } } } } # 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 } # Merge parameters over config over defaults $keyPath = if ($ServiceAccountKeyPath) { $ServiceAccountKeyPath } elseif ($config) { $config.google.serviceAccountKeyPath } else { $null } $admin = if ($AdminEmail) { $AdminEmail } elseif ($config) { $config.google.adminEmail } else { $null } $outDir = if ($OutputDirectory) { $OutputDirectory } elseif ($config -and $config.output.directory) { $config.output.directory } else { Join-Path (Get-GuerrillaDataRoot) 'Reports' } # 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. The staged temp key is cleaned in the finally below. if (-not $keyPath) { $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 $keyPath = $tempSaPath } } if (-not $admin) { $admin = Get-SafehouseSecret -VaultKey 'GUERRILLA_GWS_SA_ADMIN_EMAIL' -VaultName $vaultName } # Test mode renders zeroed timestamps for deterministic demo/sample output. $script:GuerrillaTestMode = [bool]$TestMode # Validate required parameters # --- Test mode: synthesize an all-FAIL report without touching a real tenant --- # Normalize the student-OU designation once; it flows into the data bag # (checks), the run record, and the baseline lookup (series identity). $studentOus = @(ConvertTo-GuerrillaStudentOuList -StudentOu $StudentOU -EnsureLeadingSlash) if ($TestMode) { if (-not $Quiet) { Write-OperationHeader -Operation 'GWS AUDIT (TEST MODE)' -Mode 'Simulation' -Target 'testmode.local' -DaysBack 0 } $admin = 'admin@testmode.local' $auditData = @{ Tenant = @{ Domain = 'testmode.local' }; Errors = @{}; StudentOUs = $studentOus } $allFindings = Get-GuerrillaSimulatedFindings -Platform GoogleWorkspace } else { if (-not $keyPath) { throw 'ServiceAccountKeyPath is required. Provide it as a parameter, store it in the safehouse (Set-Safehouse), or set it in config.' } if (-not $admin) { throw 'AdminEmail is required. Provide it as a parameter, store it in the safehouse (Set-Safehouse), or set it in config.' } # --- Operation header --- if (-not $Quiet) { Write-OperationHeader -Operation 'GWS AUDIT' -Mode 'Config' -Target $admin -DaysBack 0 } # --- Determine scopes needed --- $scopes = Get-GWSScopes -Categories $Categories # --- Authenticate --- if (-not $Quiet) { Write-ProgressLine -Phase AUDITING -Message 'Authenticating to Google Workspace' } $accessToken = Get-GoogleAccessToken -ServiceAccountKeyPath $keyPath -AdminEmail $admin -Scopes $scopes # --- Collect data --- if (-not $Quiet) { Write-ProgressLine -Phase AUDITING -Message 'Beginning data collection' } $auditData = Get-GWSAuditData ` -AccessToken $accessToken ` -ServiceAccountKeyPath $keyPath ` -AdminEmail $admin ` -Categories $Categories ` -UserSampleSize $UserSampleSize ` -TargetOU $TargetOU ` -StudentOU $studentOus ` -Quick:$Quick ` -Quiet:$Quiet $auditData.StudentOUs = $studentOus # 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 GWS -Message 'Evaluating configuration checks' } $allFindings = [System.Collections.Generic.List[PSCustomObject]]::new() $categoryMap = @{ Authentication = 'Invoke-AuthenticationChecks' EmailSecurity = 'Invoke-EmailSecurityChecks' DriveSecurity = 'Invoke-DriveSecurityChecks' OAuthSecurity = 'Invoke-OAuthSecurityChecks' AdminManagement = 'Invoke-AdminManagementChecks' Collaboration = 'Invoke-CollaborationChecks' DeviceManagement = 'Invoke-DeviceManagementChecks' LoggingAlerting = 'Invoke-LoggingAlertingChecks' GwsService = 'Invoke-GwsServiceChecks' Tradecraft = 'Invoke-GoogleTradecraftChecks' K12 = 'Invoke-K12Checks' } $categoriesToRun = if ($Categories -contains 'All') { $categoryMap.Keys } else { $Categories } foreach ($cat in $categoriesToRun) { $funcName = $categoryMap[$cat] if (-not $funcName) { continue } if (Get-Command $funcName -ErrorAction SilentlyContinue) { if (-not $Quiet) { Write-ProgressLine -Phase GWS -Message $cat } try { $catFindings = & $funcName -AuditData $auditData -OrgUnitPath $TargetOU foreach ($f in @($catFindings)) { $allFindings.Add($f) } $passed = @($catFindings | Where-Object Status -eq 'PASS').Count $failed = @($catFindings | Where-Object Status -eq 'FAIL').Count if (-not $Quiet) { Write-ProgressLine -Phase GWS -Message $cat -Detail "P:$passed F:$failed / $($catFindings.Count)" } } catch { Write-Warning "Category $cat failed: $_" # A thrown category must not vanish from the run record: synthesize a # Not-Assessed (ERROR) finding per check so the next comparison shows # lost visibility instead of a benign "retired" check set. foreach ($na in @(Get-GuerrillaFailedCategoryFinding -CategoryFunction $funcName -Reason "$_" -OrgUnitPath $TargetOU)) { $allFindings.Add($na) } } } } } # end if (-not $TestMode) # --- Score --- if (-not $Quiet) { Write-ProgressLine -Phase GWS -Message 'Calculating posture score' } $scoreResult = Get-AuditPostureScore -Findings @($allFindings) $overallScore = $scoreResult.OverallScore $scoreLabel = Get-AuditScoreLabel -Score $overallScore # --- Run-over-run comparison against the local run history --- # Built now, persisted only after reports are written: a crashed run # never becomes a baseline. $runRecord = $null $runDiff = $null $tenantDomain = $auditData.Tenant.Domain ?? $admin.Split('@')[-1] if (-not $NoDelta -and -not $TestMode) { if (-not $Quiet) { Write-ProgressLine -Phase GWS -Message 'Comparing against previous run' } try { $runRecord = New-GuerrillaRunRecord -Findings @($allFindings) -Platforms @('GWS') ` -TargetId @($tenantDomain) -ScanId $scanId -OverallScore $overallScore ` -TargetOu $TargetOU -StudentOu $studentOus $previousRun = Get-GuerrillaPreviousRun -Platforms @('GWS') -TargetHash $runRecord.scope.targetHash ` -TargetOu $TargetOU -StudentOu $studentOus $runDiff = Compare-GuerrillaRun -Previous $previousRun -Current $runRecord } catch { Write-Warning "Run comparison unavailable: $_" } } # --- Severity counts --- $failFindings = @($allFindings | Where-Object Status -eq 'FAIL') $critCount = @($failFindings | Where-Object Severity -eq 'Critical').Count $highCount = @($failFindings | Where-Object Severity -eq 'High').Count $medCount = @($failFindings | Where-Object Severity -eq 'Medium').Count $lowCount = @($failFindings | Where-Object Severity -eq 'Low').Count $passCount = @($allFindings | Where-Object Status -eq 'PASS').Count $failCount = $failFindings.Count $warnCount = @($allFindings | Where-Object Status -eq 'WARN').Count $skipCount = @($allFindings | Where-Object Status -in @('SKIP', 'ERROR')).Count # --- Console report --- if (-not $Quiet) { Write-GWSReport ` -OverallScore $overallScore ` -ScoreLabel $scoreLabel ` -CategoryScores $scoreResult.CategoryScores ` -TotalChecks $allFindings.Count ` -PassCount $passCount ` -FailCount $failCount ` -WarnCount $warnCount ` -SkipCount $skipCount ` -CriticalCount $critCount ` -HighCount $highCount ` -MediumCount $medCount ` -LowCount $lowCount ` -TopFindings @($allFindings) } # --- Generate reports --- $csvPath = $null; $htmlPath = $null; $jsonPath = $null 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 { Get-Date -Format 'yyyyMMdd_HHmmss' } $genCsv = if ($config -and $null -ne $config.output.generateCsv) { $config.output.generateCsv } else { $true } $genHtml = if ($config -and $null -ne $config.output.generateHtml) { $config.output.generateHtml } else { $true } $genJson = if ($config -and $null -ne $config.output.generateJson) { $config.output.generateJson } else { $true } if ($genCsv) { $csvPath = Join-Path $outDir "gws_report_$timestamp.csv" Export-GWSReportCsv -Findings @($allFindings) -FilePath $csvPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'CSV report' -Detail $csvPath } } if ($genHtml) { if (-not $PSBoundParameters.ContainsKey('ReportStyle') -and $config -and $config.output -and ($config.output.reportStyle -in 'Auto', 'Light', 'Dark', 'Guerrilla', 'Professional', 'Slate')) { $ReportStyle = [string]$config.output.reportStyle } $reportBranding = Get-GuerrillaBranding -Config $config $htmlPath = Join-Path $outDir "gws_report_$timestamp.html" Export-GWSReportHtml ` -Findings @($allFindings) ` -OverallScore $overallScore ` -ScoreLabel $scoreLabel ` -CategoryScores $scoreResult.CategoryScores ` -TenantDomain $tenantDomain ` -RunDiff $runDiff ` -FilePath $htmlPath ` -Style $ReportStyle ` -Branding $reportBranding if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'HTML report' -Detail $htmlPath } } if ($genJson) { $jsonPath = Join-Path $outDir "gws_report_$timestamp.json" Export-GWSReportJson ` -Findings @($allFindings) ` -OverallScore $overallScore ` -ScoreLabel $scoreLabel ` -CategoryScores $scoreResult.CategoryScores ` -TenantDomain $tenantDomain ` -ScanId $scanId ` -RunDiff $runDiff ` -FilePath $jsonPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'JSON report' -Detail $jsonPath } } } # --- 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: $_" } } # --- Emit result object --- $result = [PSCustomObject]@{ PSTypeName = 'Guerrilla.AuditResult' ScanId = $scanId Timestamp = $scanStart TenantDomain = $tenantDomain OverallScore = $overallScore ScoreLabel = $scoreLabel CategoryScores = $scoreResult.CategoryScores TotalChecks = $allFindings.Count PassCount = $passCount FailCount = $failCount WarnCount = $warnCount SkipCount = $skipCount CriticalCount = $critCount HighCount = $highCount MediumCount = $medCount LowCount = $lowCount Findings = @($allFindings) RunComparison = $runDiff HtmlReportPath = $htmlPath CsvReportPath = $csvPath JsonReportPath = $jsonPath } return $result } finally { if ($tempSaPath -and (Test-Path $tempSaPath)) { Remove-Item -Path $tempSaPath -Force -ErrorAction SilentlyContinue } } } function Get-GWSScopes { [CmdletBinding()] param( [string[]]$Categories = @('All') ) $scopeMap = @{ Authentication = @( 'https://www.googleapis.com/auth/admin.directory.user.readonly' 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' ) EmailSecurity = @( 'https://www.googleapis.com/auth/admin.directory.user.readonly' 'https://www.googleapis.com/auth/admin.directory.domain.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' 'https://www.googleapis.com/auth/gmail.settings.basic' ) DriveSecurity = @( 'https://www.googleapis.com/auth/admin.directory.user.readonly' 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' ) OAuthSecurity = @( 'https://www.googleapis.com/auth/admin.directory.user.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' 'https://www.googleapis.com/auth/admin.reports.audit.readonly' 'https://www.googleapis.com/auth/admin.directory.domain.readonly' ) AdminManagement = @( 'https://www.googleapis.com/auth/admin.directory.user.readonly' 'https://www.googleapis.com/auth/admin.directory.group.readonly' 'https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly' 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' ) Collaboration = @( 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly' 'https://www.googleapis.com/auth/admin.directory.group.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' ) DeviceManagement = @( 'https://www.googleapis.com/auth/admin.directory.device.mobile.readonly' 'https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' 'https://www.googleapis.com/auth/chrome.management.policy.readonly' ) LoggingAlerting = @( 'https://www.googleapis.com/auth/admin.directory.customer.readonly' 'https://www.googleapis.com/auth/apps.alerts' ) # Sites / Classroom / Gemini settings are read from the Cloud Identity Policy API, # which Get-GoogleCloudIdentityPolicies requests under its OWN isolated token # (cloud-identity.policies.readonly). This category only needs the shared-token # customer scope; the policy scope is intentionally not added to the shared set so # tenants that haven't delegated it still authenticate (the checks then SKIP). GwsService = @( 'https://www.googleapis.com/auth/admin.directory.customer.readonly' ) # K12 baseline checks read OU-targeted policies (isolated policy token, see # above), plus users/roles/delegation/devices from the shared token. K12 = @( 'https://www.googleapis.com/auth/admin.directory.user.readonly' 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly' 'https://www.googleapis.com/auth/admin.directory.customer.readonly' 'https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly' 'https://www.googleapis.com/auth/admin.directory.domain.readonly' 'https://www.googleapis.com/auth/admin.reports.audit.readonly' 'https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly' 'https://www.googleapis.com/auth/chrome.management.policy.readonly' ) } $allScopes = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $categoriesToInclude = if ($Categories -contains 'All') { $scopeMap.Keys } else { $Categories } foreach ($cat in $categoriesToInclude) { if ($scopeMap.ContainsKey($cat)) { foreach ($scope in $scopeMap[$cat]) { [void]$allScopes.Add($scope) } } } return @($allScopes) } |