Public/Invoke-Lookout.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-Lookout { <# .SYNOPSIS Continuous Google Workspace security-posture (configuration-drift) monitoring. .DESCRIPTION Invoke-Lookout is the Google Workspace theater of PSGuerrilla's continuous-monitoring suite (alongside Invoke-Surveillance for Entra sign-in risk, Invoke-Watchtower for Active Directory baseline change, and Invoke-Wiretap for M365 audit logs). It runs the read-only Fortification posture audit, stores the result as a baseline, and on each subsequent run diffs the current posture against that baseline — surfacing newly-FAILing controls (drift) and controls that have been resolved, plus the change in the overall posture score. It complements Invoke-Recon (which watches user *behaviour* for compromise) by watching the tenant's *configuration* for regressions. The first run establishes the baseline (no drift reported). Subsequent runs report the delta. This makes NO changes to Google Workspace — it only reads policy/config (the same read-only collection Invoke-Fortification performs) and writes local state. Pair with Register-Patrol to run it on a schedule with alert dispatch; new failures are surfaced on the result's .NewThreats so the patrol alert wiring picks them up. .PARAMETER ServiceAccountKeyPath Path to the Google service-account JSON key. Falls back to config/vault if omitted. .PARAMETER AdminEmail Delegated super-admin to impersonate. Falls back to config/vault if omitted. .PARAMETER TargetOU Org-unit path to audit. Default: '/'. .PARAMETER ScanMode Fast (skips the slow per-user Gmail crawl, via Fortification -Quick) or Full. Default: Fast. .PARAMETER Force Re-establish the baseline from the current posture instead of diffing against the stored one. .EXAMPLE Invoke-Lookout # First run establishes the Google Workspace posture baseline. .EXAMPLE Invoke-Lookout -ScanMode Full # Subsequent run; reports controls that newly FAIL (drift) and ones that were resolved. .NOTES Baseline state is stored under the per-user PSGuerrilla data root (theater 'workspace'). Read-only against Google Workspace. #> [CmdletBinding()] param( [string]$ServiceAccountKeyPath, [string]$AdminEmail, [string]$TargetOU = '/', [switch]$IncludeChildOUs, [ValidateSet('Fast', 'Full')] [string]$ScanMode = 'Fast', [string]$OutputDirectory, [switch]$Force, [switch]$NoReports, [switch]$Quiet, [Alias('RuntimeConfig')] [string]$ConfigPath, [Alias('MissionConfig')] [string]$ConfigFile, [string]$VaultName = 'PSGuerrilla' ) $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath } $scanId = [guid]::NewGuid().ToString('N').Substring(0, 12) $timestamp = [datetime]::UtcNow $timestampStr = $timestamp.ToString('yyyy-MM-dd_HHmmss') if (-not $Quiet) { $target = if ($AdminEmail) { $AdminEmail } else { '(config/vault)' } try { Write-OperationHeader -Operation 'LOOKOUT SWEEP' -Mode $ScanMode -Target $target } catch { } } # ── 1. Collect current Google Workspace posture (read-only) ──────────────── if (-not $Quiet) { try { Write-ProgressLine -Phase SCANNING -Message 'Collecting Google Workspace posture snapshot' -Detail "($ScanMode mode)" } catch { } } $fortParams = @{ Quiet = $true; NoReports = $true; TargetOU = $TargetOU; VaultName = $VaultName } if ($PSBoundParameters.ContainsKey('ServiceAccountKeyPath')) { $fortParams.ServiceAccountKeyPath = $ServiceAccountKeyPath } if ($PSBoundParameters.ContainsKey('AdminEmail')) { $fortParams.AdminEmail = $AdminEmail } if ($ConfigPath) { $fortParams.ConfigPath = $ConfigPath } if ($ConfigFile) { $fortParams.ConfigFile = $ConfigFile } if ($IncludeChildOUs) { $fortParams.IncludeChildOUs = $true } if ($ScanMode -eq 'Fast') { $fortParams.Quick = $true } $fort = Invoke-Fortification @fortParams $findings = @($fort.Findings) $currentScore = if ($null -ne $fort.OverallScore) { $fort.OverallScore } else { 0 } if ($findings.Count -eq 0) { if (-not $Quiet) { Write-Warning 'LOOKOUT: Fortification returned no findings (no Workspace connection / credentials?). Nothing to baseline.' } return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.LookoutResult' ScanId = $scanId Timestamp = $timestamp Theater = 'GoogleWorkspace' ScanMode = $ScanMode BaselineEstablished = $false TotalChangesDetected = 0 CriticalCount = 0; HighCount = 0; MediumCount = 0; LowCount = 0 NewThreats = @(); NewFailures = @(); Resolved = @() ScoreChange = 0; CurrentScore = 0; PreviousScore = 0 ReportPaths = @{} } } # Lowercase-keyed projection for storage — Compare-FortificationState reads the previous # side via .checkId/.status/.orgUnitPath (the JSON-round-tripped shape). $storeFindings = @($findings | ForEach-Object { @{ checkId = $_.CheckId checkName = $_.CheckName category = $_.Category severity = $_.Severity status = $_.Status currentValue = $_.CurrentValue orgUnitPath = $_.OrgUnitPath } }) # ── 2. Load theater state ────────────────────────────────────────────────── $theaterState = Get-TheaterState -Theater 'workspace' -ConfigPath $cfgPath $isFirstRun = $null -eq $theaterState # ── 3. First run / Force: establish baseline and return ──────────────────── if ($isFirstRun -or $Force) { $newState = @{ schemaVersion = 1 theater = 'workspace' findings = $storeFindings overallScore = $currentScore lastScanId = $scanId lastScanTimestamp = $timestamp.ToString('o') scanHistory = @(@{ scanId = $scanId; timestamp = $timestamp.ToString('o'); mode = $ScanMode result = 'baseline_established'; changes = 0 }) } Save-TheaterState -Theater 'workspace' -State $newState -ConfigPath $cfgPath if (-not $Quiet) { $reason = if ($Force) { 'Force flag set' } else { 'First run' } try { Write-ProgressLine -Phase SCANNING -Message "Baseline established ($reason)" -Detail "score: $currentScore" Write-Host '' Write-GuerrillaText ('=' * 62) -Color Dim Write-GuerrillaText ' LOOKOUT: Workspace baseline saved. No comparison performed.' -Color Sage Write-GuerrillaText ' Run again to detect configuration drift against this baseline.' -Color Dim Write-GuerrillaText ('=' * 62) -Color Dim } catch { Write-Host "LOOKOUT: Workspace baseline established ($reason); score $currentScore." } } return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.LookoutResult' ScanId = $scanId Timestamp = $timestamp Theater = 'GoogleWorkspace' ScanMode = $ScanMode BaselineEstablished = $true TotalChangesDetected = 0 CriticalCount = 0; HighCount = 0; MediumCount = 0; LowCount = 0 NewThreats = @(); NewFailures = @(); Resolved = @() ScoreChange = 0; CurrentScore = $currentScore; PreviousScore = $currentScore ReportPaths = @{} } } # ── 4. Diff current posture against baseline ─────────────────────────────── if (-not $Quiet) { try { Write-ProgressLine -Phase ANALYZING -Message 'Comparing posture against baseline' } catch { } } $drift = Compare-FortificationState -CurrentFindings $findings -PreviousState $theaterState $newFailures = @($drift.NewFailures) $resolved = @($drift.Resolved) # New failures become .NewThreats so Register-Patrol's alert wiring picks them up. $newThreats = @($newFailures | ForEach-Object { [PSCustomObject]@{ DetectionId = $_.CheckId DetectionName = $_.CheckName Severity = $_.Severity Description = $_.CurrentValue OrgUnitPath = $_.OrgUnitPath PreviousStatus = $_.PreviousStatus IsNew = $true } }) $criticalCount = @($newFailures | Where-Object { "$($_.Severity)" -match '(?i)^crit' }).Count $highCount = @($newFailures | Where-Object { "$($_.Severity)" -match '(?i)^high' }).Count $mediumCount = @($newFailures | Where-Object { "$($_.Severity)" -match '(?i)^med' }).Count $lowCount = @($newFailures | Where-Object { "$($_.Severity)" -match '(?i)^low' }).Count # ── 5. Save updated baseline ─────────────────────────────────────────────── $theaterState['findings'] = $storeFindings $theaterState['overallScore'] = $currentScore $theaterState['lastScanId'] = $scanId $theaterState['lastScanTimestamp'] = $timestamp.ToString('o') $theaterState['scanHistory'] = @($theaterState.scanHistory) + @(@{ scanId = $scanId; timestamp = $timestamp.ToString('o'); mode = $ScanMode result = if ($newFailures.Count -gt 0) { 'drift_detected' } else { 'clean' } changes = $newFailures.Count }) Save-TheaterState -Theater 'workspace' -State $theaterState -ConfigPath $cfgPath # ── 6. Console summary ───────────────────────────────────────────────────── if (-not $Quiet) { try { $arrow = if ($drift.ScoreChange -gt 0) { "+$($drift.ScoreChange)" } else { "$($drift.ScoreChange)" } Write-ProgressLine -Phase REPORTING -Message "Drift: $($newFailures.Count) new failure(s), $($resolved.Count) resolved" -Detail "score $($drift.PreviousScore) -> $($drift.CurrentScore) ($arrow)" } catch { Write-Host "LOOKOUT: $($newFailures.Count) new failure(s), $($resolved.Count) resolved; score $($drift.PreviousScore) -> $($drift.CurrentScore)." } } # ── 7. Optional JSON drift report ────────────────────────────────────────── $reportPaths = @{} if (-not $NoReports -and $newFailures.Count -gt 0) { $outDir = if ($OutputDirectory) { $OutputDirectory } else { Join-Path (Get-PSGuerrillaDataRoot) 'Reports' } if (-not (Test-Path $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } $jsonPath = Join-Path $outDir "lookout_workspace_${timestampStr}.json" @{ scanId = $scanId timestamp = $timestamp.ToString('o') theater = 'GoogleWorkspace' scanMode = $ScanMode previousScore = $drift.PreviousScore currentScore = $drift.CurrentScore scoreChange = $drift.ScoreChange newFailures = @($newFailures) resolved = @($resolved) } | ConvertTo-Json -Depth 6 | Set-Content -Path $jsonPath -Encoding utf8 $reportPaths['json'] = $jsonPath if (-not $Quiet) { try { Write-ProgressLine -Phase REPORTING -Message 'Drift report exported' -Detail $jsonPath } catch { } } } # ── 8. Return result ─────────────────────────────────────────────────────── return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.LookoutResult' ScanId = $scanId Timestamp = $timestamp Theater = 'GoogleWorkspace' ScanMode = $ScanMode BaselineEstablished = $false TotalChangesDetected = $newFailures.Count CriticalCount = $criticalCount; HighCount = $highCount; MediumCount = $mediumCount; LowCount = $lowCount NewThreats = @($newThreats) NewFailures = @($newFailures) Resolved = @($resolved) ScoreChange = $drift.ScoreChange CurrentScore = $drift.CurrentScore PreviousScore = $drift.PreviousScore ReportPaths = $reportPaths } } |