Public/Invoke-Recon.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-Recon { [CmdletBinding()] param( [string]$ServiceAccountKeyPath, [string]$AdminEmail, [ValidateRange(1, 180)] [int]$DaysBack, [ValidateSet('Fast', 'Full')] [string]$ScanMode, [string]$OutputDirectory, [switch]$Force, [switch]$NoReports, [switch]$NoGeoIp, [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 # 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 monitoring interval from mission config $gwsEnv = $missionCfg.EnabledEnvironments['googleWorkspace'] if ($gwsEnv -and $gwsEnv.monitoring -and $gwsEnv.monitoring.intervalMinutes) { # Store for potential use by Register-Patrol $script:MissionMonitorInterval = $gwsEnv.monitoring.intervalMinutes } # Extract detection filter from mission config if ($gwsEnv -and $gwsEnv.monitoring -and $gwsEnv.monitoring.detections) { $script:DetectionFilter = $gwsEnv.monitoring.detections } } $scanId = [guid]::NewGuid().ToString() $scanStart = [datetime]::UtcNow # --- Load config --- $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath } $config = $null if (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 } $days = if ($PSBoundParameters.ContainsKey('DaysBack')) { $DaysBack } elseif ($config -and $config.google.defaultDaysBack) { $config.google.defaultDaysBack } else { 30 } $mode = if ($ScanMode) { $ScanMode } elseif ($config -and $config.google.defaultScanMode) { $config.google.defaultScanMode } else { 'Fast' } $outDir = if ($OutputDirectory) { $OutputDirectory } elseif ($config -and $config.output.directory) { $config.output.directory } else { Join-Path (Get-PSGuerrillaDataRoot) 'Reports' } # Validate required parameters if (-not $keyPath) { throw 'ServiceAccountKeyPath is required. Provide it as a parameter or set it in config.' } if (-not $admin) { throw 'AdminEmail is required. Provide it as a parameter or set it in config.' } # --- Operation header --- if (-not $Quiet) { Write-OperationHeader -Operation 'RECONNAISSANCE SWEEP' -Mode $mode -Target $admin -DaysBack $days } # --- Load state --- $state = Get-OperationState -ConfigPath $cfgPath $startTime = $null if ($Force -or -not $state) { # First run or forced: look back $days $startTime = [datetime]::UtcNow.AddDays(-$days) if (-not $state) { if (-not $Quiet) { Write-ProgressLine -Phase INFO -Message "First run" -Detail "scanning $days days of history" } } else { if (-not $Quiet) { Write-ProgressLine -Phase INFO -Message "Forced rescan" -Detail "scanning $days days of history" } } } else { # Subsequent run: use watermark $startTime = [datetime]::Parse($state.watermark).ToUniversalTime() $daysSinceWatermark = [Math]::Round(([datetime]::UtcNow - $startTime).TotalDays, 1) if (-not $Quiet) { Write-ProgressLine -Phase INFO -Message "Incremental scan" -Detail "since watermark ($daysSinceWatermark days)" } } # --- Authenticate --- if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Authenticating to Google Workspace' } $accessToken = Get-GoogleAccessToken -ServiceAccountKeyPath $keyPath -AdminEmail $admin # --- Collect events --- if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Login events' } $loginEvents = Invoke-GoogleReportsApi -AccessToken $accessToken -ApplicationName 'login' -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Login events' -Detail "$($loginEvents.Count) found" } if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Admin events' } $adminEvents = Invoke-GoogleReportsApi -AccessToken $accessToken -ApplicationName 'admin' -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Admin events' -Detail "$($adminEvents.Count) found" } $tokenEvents = @() $accountEvents = @() $driveEvents = @() if ($mode -eq 'Full') { if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'OAuth token events (full mode)' } $tokenEvents = Invoke-GoogleReportsApi -AccessToken $accessToken -ApplicationName 'token' -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'OAuth token events' -Detail "$($tokenEvents.Count) found" } if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'User account events' } $accountEvents = Invoke-GoogleReportsApi -AccessToken $accessToken -ApplicationName 'user_accounts' -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'User account events' -Detail "$($accountEvents.Count) found" } if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Drive events (full mode)' } $driveEvents = Invoke-GoogleReportsApi -AccessToken $accessToken -ApplicationName 'drive' -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Drive events' -Detail "$($driveEvents.Count) found" } } $totalEvents = $loginEvents.Count + $adminEvents.Count + $tokenEvents.Count + $accountEvents.Count + $driveEvents.Count if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'Total events collected' -Detail "$($totalEvents.ToString('N0'))" } # --- GeoIP enrichment --- $geoData = @{} if (-not $NoGeoIp) { $allIps = @($loginEvents | ForEach-Object { $_.IpAddress } | Where-Object { $_ } | Sort-Object -Unique) if ($allIps.Count -gt 0) { if (-not $Quiet) { Write-ProgressLine -Phase ENRICHING -Message "GeoIP: $($allIps.Count) unique IPs" } $geoData = Get-IpGeoData -IpAddresses $allIps if (-not $Quiet) { Write-ProgressLine -Phase ENRICHING -Message "GeoIP enrichment" -Detail "done" } } } # --- Build known compromised users set --- $knownCompromised = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) if ($config -and $config.detection.knownCompromisedUsers) { foreach ($u in $config.detection.knownCompromisedUsers) { [void]$knownCompromised.Add($u) } } # --- Identify remediated users from admin events --- $remediatedUsers = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($event in $adminEvents) { if ($event.EventName -in @('CHANGE_PASSWORD', 'RESET_SIGNIN_COOKIES', 'TURN_OFF_2_STEP_VERIFICATION')) { $targetEmail = $event.Params['USER_EMAIL'] if ($targetEmail) { [void]$remediatedUsers.Add($targetEmail) } } } # --- Bucket events by user --- $userLoginEvents = @{} $userTokenEvents = @{} $userAccountEvents = @{} $userAdminEvents = @{} $userDriveEvents = @{} foreach ($event in $loginEvents) { $user = $event.User if (-not $user) { continue } if (-not $userLoginEvents.ContainsKey($user)) { $userLoginEvents[$user] = [System.Collections.Generic.List[hashtable]]::new() } $userLoginEvents[$user].Add($event) } foreach ($event in $tokenEvents) { $user = $event.User if (-not $user) { continue } if (-not $userTokenEvents.ContainsKey($user)) { $userTokenEvents[$user] = [System.Collections.Generic.List[hashtable]]::new() } $userTokenEvents[$user].Add($event) } foreach ($event in $accountEvents) { $user = $event.User if (-not $user) { continue } if (-not $userAccountEvents.ContainsKey($user)) { $userAccountEvents[$user] = [System.Collections.Generic.List[hashtable]]::new() } $userAccountEvents[$user].Add($event) } foreach ($event in $adminEvents) { $user = $event.User if (-not $user) { continue } if (-not $userAdminEvents.ContainsKey($user)) { $userAdminEvents[$user] = [System.Collections.Generic.List[hashtable]]::new() } $userAdminEvents[$user].Add($event) } foreach ($event in $driveEvents) { $user = $event.User if (-not $user) { continue } if (-not $userDriveEvents.ContainsKey($user)) { $userDriveEvents[$user] = [System.Collections.Generic.List[hashtable]]::new() } $userDriveEvents[$user].Add($event) } # --- Build detection config for new signals --- $detectionCfg = @{} if ($config -and $config.detection) { $det = $config.detection if ($det.businessHoursStart) { $detectionCfg.businessHoursStart = $det.businessHoursStart } if ($det.businessHoursEnd) { $detectionCfg.businessHoursEnd = $det.businessHoursEnd } if ($det.businessHoursTimezone) { $detectionCfg.businessHoursTimezone = $det.businessHoursTimezone } if ($det.businessDays) { $detectionCfg.businessDays = $det.businessDays } if ($det.impossibleTravelSpeedKmh) { $detectionCfg.impossibleTravelSpeedKmh = $det.impossibleTravelSpeedKmh } if ($det.concurrentSessionWindowMinutes) { $detectionCfg.concurrentSessionWindowMinutes = $det.concurrentSessionWindowMinutes } if ($det.bruteForceFailureThreshold) { $detectionCfg.bruteForceFailureThreshold = $det.bruteForceFailureThreshold } if ($det.bruteForceWindowMinutes) { $detectionCfg.bruteForceWindowMinutes = $det.bruteForceWindowMinutes } if ($det.bulkDownloadThreshold) { $detectionCfg.bulkDownloadThreshold = $det.bulkDownloadThreshold } if ($det.bulkDownloadWindowMinutes) { $detectionCfg.bulkDownloadWindowMinutes = $det.bulkDownloadWindowMinutes } if ($det.highRiskOAuthAppPatterns) { $detectionCfg.highRiskOAuthAppPatterns = $det.highRiskOAuthAppPatterns } } # Load previous device fingerprints from state for new-device detection $previousDevices = @{} if ($state -and $state.knownDevices) { $previousDevices = $state.knownDevices } # Extract internal domain from admin email for external sharing detection $internalDomain = '' if ($admin -match '@(.+)$') { $internalDomain = $Matches[1] } # --- Build profiles for all users --- $allUsers = @($userLoginEvents.Keys + $userTokenEvents.Keys + $userAccountEvents.Keys + $userAdminEvents.Keys + $userDriveEvents.Keys | Sort-Object -Unique) if (-not $Quiet) { Write-ProgressLine -Phase ANALYZING -Message "$($allUsers.Count) users" } $profiles = @{} foreach ($email in $allUsers) { $uLogin = if ($userLoginEvents.ContainsKey($email)) { @($userLoginEvents[$email]) } else { @() } $uToken = if ($userTokenEvents.ContainsKey($email)) { @($userTokenEvents[$email]) } else { @() } $uAccount = if ($userAccountEvents.ContainsKey($email)) { @($userAccountEvents[$email]) } else { @() } $uAdmin = if ($userAdminEvents.ContainsKey($email)) { @($userAdminEvents[$email]) } else { @() } $uDrive = if ($userDriveEvents.ContainsKey($email)) { @($userDriveEvents[$email]) } else { @() } $userPrevDevices = if ($previousDevices.ContainsKey($email)) { $previousDevices[$email] } else { @{} } $profileParams = @{ Email = $email LoginEvents = $uLogin TokenEvents = $uToken AccountEvents = $uAccount AdminEvents = $uAdmin DriveEvents = $uDrive GeoData = $geoData IsKnownCompromised = $knownCompromised.Contains($email) WasRemediated = $remediatedUsers.Contains($email) DetectionConfig = $detectionCfg PreviousDevices = $userPrevDevices InternalDomain = $internalDomain } if ($script:DetectionFilter) { $profileParams['DetectionFilter'] = $script:DetectionFilter } $profile = New-UserCompromiseProfile @profileParams $profiles[$email] = $profile } # --- Pass 2 (Fast mode): Targeted token fetch for flagged users --- if ($mode -eq 'Fast') { $suspects = @($profiles.Values | Where-Object { $_.ThreatScore -gt 0 -and $_.ThreatLevel -in @('CRITICAL', 'HIGH', 'MEDIUM') } | ForEach-Object { $_.Email }) if ($suspects.Count -gt 0) { if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message "Pass 2: OAuth for $($suspects.Count) flagged users" } foreach ($userEmail in $suspects) { $userTokenEvts = Invoke-GoogleReportsApi ` -AccessToken $accessToken ` -ApplicationName 'token' ` -UserKey $userEmail ` -StartTime $startTime ` -Quiet:$Quiet if ($userTokenEvts.Count -gt 0) { $totalEvents += $userTokenEvts.Count $profile = $profiles[$userEmail] # Re-analyze OAuth signals $profile.SuspiciousOAuthGrants = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($event in $userTokenEvts) { $ip = $event.IpAddress $ipClass = if ($ip) { Get-CloudIpClassification -IpAddress $ip } else { '' } $isCloud = $ipClass -and ($ipClass -eq 'known_attacker' -or $script:CloudProviderClasses.Contains($ipClass)) if ($event.EventName -eq 'authorize' -and $isCloud) { $profile.SuspiciousOAuthGrants.Add([PSCustomObject]@{ Timestamp = $event.Timestamp User = $event.User EventName = $event.EventName IpAddress = $ip IpClass = $ipClass GeoCountry = '' Source = $event.Source Params = $event.Params }) } } # Re-score $profiles[$userEmail] = Get-ThreatScore -Profile $profile } } } else { if (-not $Quiet) { Write-ProgressLine -Phase SCANNING -Message 'No flagged users' -Detail 'skipping OAuth fetch' } } } # --- Sort and categorize --- $allProfiles = @($profiles.Values | Sort-Object -Property ThreatScore -Descending) $flagged = @($allProfiles | Where-Object { $_.ThreatScore -gt 0 }) $cleanCount = $allProfiles.Count - $flagged.Count # --- Determine new threats (compare against state) --- $alertedUsers = if ($state -and $state.alertedUsers -and -not $Force) { $state.alertedUsers } else { @{} } $newThreats = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($profile in $flagged) { $isNew = $false if (-not $alertedUsers.ContainsKey($profile.Email)) { $isNew = $true } else { $prev = $alertedUsers[$profile.Email] # Escalation check $levelOrder = @{ 'LOW' = 1; 'MEDIUM' = 2; 'HIGH' = 3; 'CRITICAL' = 4 } $prevLevel = $levelOrder[$prev.lastThreatLevel] $currLevel = $levelOrder[$profile.ThreatLevel] if ($currLevel -gt $prevLevel) { $isNew = $true } # New indicator check $prevHashes = [System.Collections.Generic.HashSet[string]]::new() if ($prev.indicatorHashes) { foreach ($h in $prev.indicatorHashes) { [void]$prevHashes.Add($h) } } foreach ($ind in $profile.Indicators) { $hash = [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::HashData( [System.Text.Encoding]::UTF8.GetBytes($ind) ) ).Replace('-', '').Substring(0, 16) if (-not $prevHashes.Contains($hash)) { $isNew = $true break } } } if ($isNew) { $newThreats.Add($profile) } } # --- Print themed field report --- if (-not $Quiet) { $critCount = @($flagged | Where-Object ThreatLevel -eq 'CRITICAL').Count $highCount = @($flagged | Where-Object ThreatLevel -eq 'HIGH').Count $medCount = @($flagged | Where-Object ThreatLevel -eq 'MEDIUM').Count $lowCount = @($flagged | Where-Object ThreatLevel -eq 'LOW').Count Write-FieldReport ` -TotalUsers $allProfiles.Count ` -FlaggedCount $flagged.Count ` -CleanCount $cleanCount ` -CriticalCount $critCount ` -HighCount $highCount ` -MediumCount $medCount ` -LowCount $lowCount ` -NewThreats $newThreats.Count ` -TotalEvents $totalEvents ` -FlaggedUsers $flagged if ($newThreats.Count -gt 0) { Write-InterceptAlert -NewThreats @($newThreats) } } # --- 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 } $timestamp = 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 -and $flagged.Count -gt 0) { $csvPath = Join-Path $outDir "field_report_$timestamp.csv" Export-FieldReportCsv -Profiles $flagged -FilePath $csvPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message "CSV report" -Detail $csvPath } } if ($genHtml) { $htmlPath = Join-Path $outDir "field_report_$timestamp.html" Export-FieldReportHtml -Profiles $flagged -AllProfilesCount $allProfiles.Count ` -CleanCount $cleanCount -AllEventsCount $totalEvents ` -DaysBack $days -TimestampStr (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ` -FilePath $htmlPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message "HTML report" -Detail $htmlPath } } if ($genJson -and $newThreats.Count -gt 0) { $jsonPath = Join-Path $outDir "NEW_COMPROMISES_SIGNAL_$timestamp.json" Export-FieldReportJson -Profiles @($newThreats) -FilePath $jsonPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message "Signal JSON" -Detail $jsonPath } } } # --- Update state --- $newAlertedUsers = @{} if ($alertedUsers) { foreach ($key in $alertedUsers.Keys) { $newAlertedUsers[$key] = $alertedUsers[$key] } } foreach ($profile in $flagged) { $indicatorHashes = @($profile.Indicators | ForEach-Object { [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::HashData( [System.Text.Encoding]::UTF8.GetBytes($_) ) ).Replace('-', '').Substring(0, 16) }) if ($newAlertedUsers.ContainsKey($profile.Email)) { $existing = $newAlertedUsers[$profile.Email] $existing.lastThreatLevel = $profile.ThreatLevel $existing.lastThreatScore = $profile.ThreatScore $existing.indicatorHashes = $indicatorHashes if ($profile.Email -in $newThreats.Email) { $existing.lastAlerted = [datetime]::UtcNow.ToString('o') $existing.alertCount = ($existing.alertCount ?? 0) + 1 } } else { $newAlertedUsers[$profile.Email] = @{ firstDetected = [datetime]::UtcNow.ToString('o') lastAlerted = [datetime]::UtcNow.ToString('o') lastThreatLevel = $profile.ThreatLevel lastThreatScore = $profile.ThreatScore alertCount = 1 indicatorHashes = $indicatorHashes } } } $scanHistory = [System.Collections.Generic.List[object]]::new() if ($state -and $state.scanHistory) { foreach ($entry in $state.scanHistory) { $scanHistory.Add($entry) } } $scanHistory.Add(@{ scanId = $scanId timestamp = [datetime]::UtcNow.ToString('o') daysAnalyzed = $days mode = $mode criticalCount = @($flagged | Where-Object ThreatLevel -eq 'CRITICAL').Count highCount = @($flagged | Where-Object ThreatLevel -eq 'HIGH').Count flaggedCount = $flagged.Count totalUsers = $allProfiles.Count }) # Collect device fingerprints for future new-device detection $newKnownDevices = if ($previousDevices) { @{} + $previousDevices } else { @{} } foreach ($p in $allProfiles) { if ($p.NewDevices.Count -gt 0) { if (-not $newKnownDevices.ContainsKey($p.Email)) { $newKnownDevices[$p.Email] = @{} } foreach ($dev in $p.NewDevices) { $newKnownDevices[$p.Email][$dev.Fingerprint] = @{ firstSeen = $dev.Timestamp ipAddress = $dev.IpAddress } } } } $newState = @{ schemaVersion = 1 watermark = [datetime]::UtcNow.ToString('o') lastScanId = $scanId alertedUsers = $newAlertedUsers scanHistory = $scanHistory knownDevices = $newKnownDevices } Save-OperationState -State $newState -ConfigPath $cfgPath # --- Emit result object --- $result = [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.ScanResult' ScanId = $scanId Timestamp = $scanStart DaysAnalyzed = $days ScanMode = $mode TotalUsersScanned = $allProfiles.Count TotalEventsAnalyzed = $totalEvents CriticalCount = @($flagged | Where-Object ThreatLevel -eq 'CRITICAL').Count HighCount = @($flagged | Where-Object ThreatLevel -eq 'HIGH').Count MediumCount = @($flagged | Where-Object ThreatLevel -eq 'MEDIUM').Count LowCount = @($flagged | Where-Object ThreatLevel -eq 'LOW').Count CleanCount = $cleanCount FlaggedUsers = $flagged NewThreats = @($newThreats) CsvReportPath = $csvPath HtmlReportPath = $htmlPath JsonAlertPath = $jsonPath AllProfiles = $profiles } return $result } |