Public/Invoke-Surveillance.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-Surveillance { <# .SYNOPSIS Performs continuous Entra ID security monitoring via Microsoft Graph API. .DESCRIPTION Invoke-Surveillance executes a comprehensive audit of Entra ID sign-in logs, risk detections, and directory audit events to detect identity-based threats. It monitors risky sign-ins, impossible travel, anonymous IP sign-ins, leaked credentials, password spray attacks, privileged role changes, conditional access policy modifications, service principal credential additions, federation domain changes, and more. Emulates: Microsoft Entra ID Protection, Microsoft Sentinel UEBA, and similar identity threat detection tools. .PARAMETER TenantId The Azure AD / Entra ID tenant ID. .PARAMETER ClientId The application (client) ID for authentication. .PARAMETER CertificateThumbprint Certificate thumbprint for app-only authentication. .PARAMETER ClientSecret Client secret for app-only authentication. .PARAMETER DeviceCode Use device code flow for interactive authentication. .PARAMETER DaysBack Number of days to look back on first run or forced rescan. Default: 7. Range: 1-180. .PARAMETER ScanMode Fast: Sign-in events + risk detections only. Full: All three endpoints including directory audits. Default: Fast. .PARAMETER OutputDirectory Directory for report output. Default: per-user data dir + /PSGuerrilla/Reports (Windows: $env:APPDATA; macOS: ~/Library/Application Support; Linux: $XDG_CONFIG_HOME or ~/.config) .PARAMETER Force Force a full rescan ignoring the watermark from previous runs. .PARAMETER NoReports Skip report generation. .PARAMETER Quiet Suppress console output. .PARAMETER ConfigPath Path to PSGuerrilla configuration file. .EXAMPLE Invoke-Surveillance -TenantId 'contoso.onmicrosoft.com' -ClientId $appId -ClientSecret $secret .EXAMPLE Invoke-Surveillance -TenantId $tenantId -ClientId $appId -DeviceCode -ScanMode Full -DaysBack 30 .EXAMPLE Invoke-Surveillance -TenantId $tenantId -ClientId $appId -CertificateThumbprint $thumb -Force #> [CmdletBinding()] param( [string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [securestring]$ClientSecret, [switch]$DeviceCode, [ValidateRange(1, 180)] [int]$DaysBack = 7, [ValidateSet('Fast', 'Full')] [string]$ScanMode = 'Fast', [string]$OutputDirectory, [switch]$Force, [switch]$NoReports, [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 Microsoft Graph credentials from vault $graphRef = $missionCfg.Config.credentials.references.microsoftGraph if ($graphRef) { if ($graphRef.tenantIdVaultKey -and -not $PSBoundParameters.ContainsKey('TenantId')) { try { $TenantId = Get-GuerrillaCredential -VaultKey $graphRef.tenantIdVaultKey -VaultName $vaultName } catch { Write-Warning "Failed to resolve TenantId from vault: $_" } } if ($graphRef.clientIdVaultKey -and -not $PSBoundParameters.ContainsKey('ClientId')) { try { $ClientId = Get-GuerrillaCredential -VaultKey $graphRef.clientIdVaultKey -VaultName $vaultName } catch { Write-Warning "Failed to resolve ClientId from vault: $_" } } if ($graphRef.vaultKey -and -not $PSBoundParameters.ContainsKey('CertificateThumbprint') -and -not $PSBoundParameters.ContainsKey('ClientSecret')) { try { $secretVal = Get-GuerrillaCredential -VaultKey $graphRef.vaultKey -VaultName $vaultName if ($graphRef.authMethod -eq 'certificate') { $CertificateThumbprint = $secretVal } else { $ClientSecret = $secretVal | ConvertTo-SecureString -AsPlainText -Force } } catch { Write-Warning "Failed to resolve Graph auth credential from vault: $_" } } } # Apply monitoring interval from mission config $entraEnv = $missionCfg.EnabledEnvironments['entraAzure'] if ($entraEnv -and $entraEnv.monitoring -and $entraEnv.monitoring.intervalMinutes) { $script:MissionMonitorInterval = $entraEnv.monitoring.intervalMinutes } # Extract detection filter from mission config if ($entraEnv -and $entraEnv.monitoring -and $entraEnv.monitoring.detections) { $script:DetectionFilter = $entraEnv.monitoring.detections } } $scanId = [guid]::NewGuid().ToString() $scanStart = [datetime]::UtcNow # --- 1. Load config --- $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath } $config = $null if ($cfgPath -and (Test-Path $cfgPath)) { try { $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable } catch { Write-Warning "Failed to load config from $cfgPath - using defaults." } } # Merge parameters over config over defaults $tenantId = if ($TenantId) { $TenantId } elseif ($config -and $config.entra.tenantId) { $config.entra.tenantId } else { $null } $clientId = if ($ClientId) { $ClientId } elseif ($config -and $config.entra.clientId) { $config.entra.clientId } else { $null } $certThumb = if ($CertificateThumbprint) { $CertificateThumbprint } elseif ($config -and $config.entra.certificateThumbprint) { $config.entra.certificateThumbprint } else { $null } $days = if ($PSBoundParameters.ContainsKey('DaysBack')) { $DaysBack } elseif ($config -and $config.entra.defaultDaysBack) { $config.entra.defaultDaysBack } else { 7 } $mode = if ($PSBoundParameters.ContainsKey('ScanMode')) { $ScanMode } elseif ($config -and $config.entra.defaultScanMode) { $config.entra.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 $tenantId) { throw 'TenantId is required. Provide it as a parameter or set entra.tenantId in config.' } if (-not $clientId) { throw 'ClientId is required. Provide it as a parameter or set entra.clientId in config.' } # --- 2. Operation header --- if (-not $Quiet) { Write-OperationHeader -Operation 'SURVEILLANCE SWEEP' -Mode $mode -Target $tenantId -DaysBack $days } # --- 3. Load theater state --- $state = Get-TheaterState -Theater 'entra' -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)" } } # --- 4. Authenticate to Microsoft Graph --- if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Authenticating to Microsoft Graph' } $authParams = @{ TenantId = $tenantId ClientId = $clientId } if ($certThumb) { $authParams['CertificateThumbprint'] = $certThumb } if ($ClientSecret) { $authParams['ClientSecret'] = $ClientSecret } if ($DeviceCode) { $authParams['DeviceCode'] = $true } try { $graphToken = Get-GraphAccessToken @authParams ` -Scopes @('https://graph.microsoft.com/.default') } catch { throw "SURVEILLANCE: Failed to authenticate to Microsoft Graph: $_" } if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Authenticated to Microsoft Graph' } # --- 5. Build detection config --- $detectionCfg = @{} if ($config -and $config.detection) { $det = $config.detection if ($det.impossibleTravelSpeedKmh) { $detectionCfg.impossibleTravelSpeedKmh = $det.impossibleTravelSpeedKmh } if ($det.auditLogGapThresholdHours) { $detectionCfg.auditLogGapThresholdHours = $det.auditLogGapThresholdHours } if ($det.entraWeights) { $detectionCfg.entraWeights = $det.entraWeights } } if ($config -and $config.entra) { $entraCfg = $config.entra if ($entraCfg.detectionWeights) { $detectionCfg.entraWeights = $entraCfg.detectionWeights } } # --- 6. Collect events --- if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Collecting sign-in events' } $signInEvents = Get-EntraSignInEvents -AccessToken $graphToken -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Sign-in events' -Detail "$($signInEvents.Count) found" } if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Collecting risk detections' } $riskDetections = Get-EntraRiskDetections -AccessToken $graphToken -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Risk detections' -Detail "$($riskDetections.Count) found" } $auditEvents = @() if ($mode -eq 'Full') { if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Collecting directory audit events (full mode)' } $auditEvents = Get-EntraDirectoryAudits -AccessToken $graphToken -StartTime $startTime -Quiet:$Quiet if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Directory audit events' -Detail "$($auditEvents.Count) found" } } $totalEvents = $signInEvents.Count + $riskDetections.Count + $auditEvents.Count if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Total events collected' -Detail "$($totalEvents.ToString('N0'))" } # --- 7. Bucket events by user principal name --- $userSignInEvents = @{} $userRiskDetections = @{} $userAuditEvents = @{} foreach ($event in $signInEvents) { $upn = $event.UserPrincipalName if (-not $upn) { continue } if (-not $userSignInEvents.ContainsKey($upn)) { $userSignInEvents[$upn] = [System.Collections.Generic.List[hashtable]]::new() } $userSignInEvents[$upn].Add($event) } foreach ($event in $riskDetections) { $upn = $event.UserPrincipalName if (-not $upn) { continue } if (-not $userRiskDetections.ContainsKey($upn)) { $userRiskDetections[$upn] = [System.Collections.Generic.List[hashtable]]::new() } $userRiskDetections[$upn].Add($event) } foreach ($event in $auditEvents) { $upn = $event.InitiatedBy if (-not $upn) { continue } if (-not $userAuditEvents.ContainsKey($upn)) { $userAuditEvents[$upn] = [System.Collections.Generic.List[hashtable]]::new() } $userAuditEvents[$upn].Add($event) } # --- 8. Build risk profiles for all users --- $allUsers = @($userSignInEvents.Keys + $userRiskDetections.Keys + $userAuditEvents.Keys | Sort-Object -Unique) if (-not $Quiet) { Write-ProgressLine -Phase ANALYZING -Message "$($allUsers.Count) identities to analyze" } $profiles = @{} foreach ($upn in $allUsers) { $userSignIns = if ($userSignInEvents.ContainsKey($upn)) { @($userSignInEvents[$upn]) } else { @() } $userRisks = if ($userRiskDetections.ContainsKey($upn)) { @($userRiskDetections[$upn]) } else { @() } $userAudits = if ($userAuditEvents.ContainsKey($upn)) { @($userAuditEvents[$upn]) } else { @() } $riskProfileParams = @{ UserPrincipalName = $upn SignInEvents = @($userSignIns) RiskDetections = @($userRisks) AuditEvents = @($userAudits) DetectionConfig = $detectionCfg } if ($script:DetectionFilter) { $riskProfileParams['DetectionFilter'] = $script:DetectionFilter } $profile = New-EntraRiskProfile @riskProfileParams $profiles[$upn] = $profile } if (-not $Quiet) { Write-ProgressLine -Phase ANALYZING -Message 'Risk profiles built' -Detail "$($profiles.Count) identities scored" } # --- 9. Sort and categorize --- $allProfiles = @($profiles.Values | Sort-Object -Property ThreatScore -Descending) $flagged = @($allProfiles | Where-Object { $_.ThreatLevel -ne 'Clean' }) $cleanCount = $allProfiles.Count - $flagged.Count $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 # --- 10. 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 $upn = $profile.UserPrincipalName if (-not $alertedUsers.ContainsKey($upn)) { $isNew = $true } else { $prev = $alertedUsers[$upn] # 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 via SHA256 hashing $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) } } # --- 11. Console report --- if (-not $Quiet) { Write-SurveillanceReport ` -TotalEntities $allProfiles.Count ` -FlaggedCount $flagged.Count ` -CleanCount $cleanCount ` -CriticalCount $criticalCount ` -HighCount $highCount ` -MediumCount $mediumCount ` -LowCount $lowCount ` -NewThreats $newThreats.Count ` -TotalEvents $totalEvents ` -FlaggedUsers @($flagged) if ($newThreats.Count -gt 0) { $interceptThreats = @($newThreats | ForEach-Object { [PSCustomObject]@{ Email = $_.UserPrincipalName ThreatScore = $_.ThreatScore ThreatLevel = $_.ThreatLevel Indicators = @($_.Indicators) } }) Write-InterceptAlert -NewThreats $interceptThreats } } # --- 12. Export reports --- $csvPath = $null; $htmlPath = $null; $jsonPath = $null if (-not $NoReports) { if (-not (Test-Path $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } $timestampStr = $scanStart.ToString('yyyyMMdd-HHmmss') $tenantLabel = $tenantId -replace '[^a-zA-Z0-9]', '_' $baseName = "surveillance-$tenantLabel-$timestampStr" $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 (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message 'Generating reports' } if ($genCsv -and $flagged.Count -gt 0) { try { $csvPath = Join-Path $outDir "$baseName.csv" Export-SurveillanceReportCsv -Profiles @($flagged) -FilePath $csvPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'CSV report' -Detail $csvPath } } catch { Write-Warning "CSV report generation failed: $_" } } if ($genHtml) { try { $htmlPath = Join-Path $outDir "$baseName.html" Export-SurveillanceReportHtml ` -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 } } catch { Write-Warning "HTML report generation failed: $_" } } if ($genJson -and $flagged.Count -gt 0) { try { $jsonPath = Join-Path $outDir "$baseName.json" Export-SurveillanceReportJson -Profiles @($flagged) -FilePath $jsonPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'JSON report' -Detail $jsonPath } } catch { Write-Warning "JSON report generation failed: $_" } } if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message "Reports saved to $outDir" } } # --- 13. Update state --- $newAlertedUsers = @{} if ($alertedUsers) { foreach ($key in $alertedUsers.Keys) { $newAlertedUsers[$key] = $alertedUsers[$key] } } foreach ($profile in $flagged) { $upn = $profile.UserPrincipalName $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($upn)) { $existing = $newAlertedUsers[$upn] $existing.lastThreatLevel = $profile.ThreatLevel $existing.lastThreatScore = $profile.ThreatScore $existing.indicatorHashes = $indicatorHashes if ($upn -in $newThreats.UserPrincipalName) { $existing.lastAlerted = [datetime]::UtcNow.ToString('o') $existing.alertCount = ($existing.alertCount ?? 0) + 1 } } else { $newAlertedUsers[$upn] = @{ firstDetected = [datetime]::UtcNow.ToString('o') lastAlerted = [datetime]::UtcNow.ToString('o') lastThreatLevel = $profile.ThreatLevel lastThreatScore = $profile.ThreatScore alertCount = 1 indicatorHashes = $indicatorHashes } } } $scanHistory = if ($state -and $state.scanHistory) { @($state.scanHistory) } else { @() } $scanHistory += @{ scanId = $scanId timestamp = [datetime]::UtcNow.ToString('o') daysAnalyzed = $days mode = $mode criticalCount = $criticalCount highCount = $highCount mediumCount = $mediumCount lowCount = $lowCount flaggedCount = $flagged.Count totalEntities = $allProfiles.Count totalEvents = $totalEvents newThreats = $newThreats.Count } $newState = @{ schemaVersion = 1 watermark = [datetime]::UtcNow.ToString('o') lastScanId = $scanId alertedUsers = $newAlertedUsers scanHistory = $scanHistory } Save-TheaterState -Theater 'entra' -State $newState -ConfigPath $cfgPath # --- 14. Complete --- $scanEnd = [datetime]::UtcNow $scanDuration = $scanEnd - $scanStart if (-not $Quiet) { Write-ProgressLine -Phase SURVEILLANCE -Message "Surveillance sweep complete in $([Math]::Round($scanDuration.TotalSeconds, 1))s" } # --- 15. Emit result object --- $result = [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.SurveillanceResult' ScanId = $scanId Timestamp = $scanStart Theater = 'EntraID' TenantId = $tenantId DaysAnalyzed = $days ScanMode = $mode TotalEntitiesScanned = $allProfiles.Count TotalEventsAnalyzed = $totalEvents CriticalCount = $criticalCount HighCount = $highCount MediumCount = $mediumCount LowCount = $lowCount FlaggedEntities = @($flagged) NewThreats = @($newThreats) AllProfiles = $profiles CsvReportPath = $csvPath HtmlReportPath = $htmlPath JsonReportPath = $jsonPath Duration = $scanDuration } return $result } |