Public/Invoke-Wiretap.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-Wiretap { <# .SYNOPSIS Performs continuous M365 security monitoring across Exchange, SharePoint, Teams, Defender, and Power Platform. .DESCRIPTION Invoke-Wiretap executes a comprehensive audit of Microsoft 365 activity logs to detect security-relevant changes and threats. It monitors transport rule modifications, mailbox forwarding rules, eDiscovery searches, DLP policy changes, external sharing modifications, Teams external access changes, bulk file exfiltration, Power Automate flow creation, Defender alert policy changes, and audit log disablements. Emulates: Microsoft 365 Defender Advanced Hunting, Microsoft Sentinel M365 analytics, Hawk (mailbox investigation), CloudSploit, Sparrow (CISA), and similar 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: Exchange transport/forwarding rules + audit log status only. Full: All M365 service categories. 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-Wiretap -TenantId 'contoso.onmicrosoft.com' -ClientId $appId -ClientSecret $secret .EXAMPLE Invoke-Wiretap -TenantId $tenantId -ClientId $appId -DeviceCode -ScanMode Full -DaysBack 30 .EXAMPLE Invoke-Wiretap -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 $m365Env = $missionCfg.EnabledEnvironments['m365'] if ($m365Env -and $m365Env.monitoring -and $m365Env.monitoring.intervalMinutes) { $script:MissionMonitorInterval = $m365Env.monitoring.intervalMinutes } # Extract detection filter from mission config if ($m365Env -and $m365Env.monitoring -and $m365Env.monitoring.detections) { $script:DetectionFilter = $m365Env.monitoring.detections } } $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 $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.m365.defaultDaysBack) { $config.m365.defaultDaysBack } else { 7 } $mode = if ($PSBoundParameters.ContainsKey('ScanMode')) { $ScanMode } elseif ($config -and $config.m365.defaultScanMode) { $config.m365.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.' } # --- Operation header --- if (-not $Quiet) { Write-OperationHeader -Operation 'WIRETAP SWEEP' -Mode $mode -Target $tenantId -DaysBack $days } # --- Load theater state --- $state = Get-TheaterState -Theater 'm365' -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 to Microsoft Graph --- if (-not $Quiet) { Write-ProgressLine -Phase WIRETAP -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 "Failed to authenticate to Microsoft Graph: $_" } if (-not $Quiet) { Write-ProgressLine -Phase WIRETAP -Message 'Authenticated to Microsoft Graph' } # --- Build detection config --- $detectionCfg = @{} if ($config -and $config.m365) { $m365Cfg = $config.m365 if ($m365Cfg.bulkExfiltrationThreshold) { $detectionCfg.bulkExfiltrationThreshold = $m365Cfg.bulkExfiltrationThreshold } if ($m365Cfg.bulkExfiltrationWindowMinutes) { $detectionCfg.bulkExfiltrationWindowMinutes = $m365Cfg.bulkExfiltrationWindowMinutes } if ($m365Cfg.externalConnectorPatterns) { $detectionCfg.externalConnectorPatterns = $m365Cfg.externalConnectorPatterns } } # --- Collect events --- if (-not $Quiet) { Write-ProgressLine -Phase WIRETAP -Message 'Collecting M365 audit events' } $categorizedEvents = Get-M365AuditEvents ` -AccessToken $graphToken ` -StartTime $startTime ` -ScanMode $mode ` -Quiet:$Quiet $totalEvents = 0 foreach ($catKey in $categorizedEvents.Keys) { if ($catKey -ne 'Errors') { $totalEvents += $categorizedEvents[$catKey].Count } } if (-not $Quiet) { Write-ProgressLine -Phase WIRETAP -Message 'Total events collected' -Detail "$($totalEvents.ToString('N0'))" } # Report collection errors if ($categorizedEvents.Errors -and $categorizedEvents.Errors.Count -gt 0 -and -not $Quiet) { Write-ProgressLine -Phase INFO -Message "Data collection had $($categorizedEvents.Errors.Count) error(s)" foreach ($errKey in $categorizedEvents.Errors.Keys) { Write-ProgressLine -Phase INFO -Message " $errKey" -Detail $categorizedEvents.Errors[$errKey] } } # --- Build change profiles --- if (-not $Quiet) { Write-ProgressLine -Phase ANALYZING -Message 'Building M365 change profiles' } $m365ProfileParams = @{ CategorizedEvents = $categorizedEvents DetectionConfig = $detectionCfg } if ($script:DetectionFilter) { $m365ProfileParams['DetectionFilter'] = $script:DetectionFilter } $changeProfile = New-M365ChangeProfile @m365ProfileParams # --- Score the profile --- if (-not $Quiet) { Write-ProgressLine -Phase ANALYZING -Message 'Scoring threat indicators' } # Load scoring weights from config if available $weights = $null if ($config -and $config.m365.weights) { $weights = $config.m365.weights } $changeProfile = Get-M365MonitorThreatScore -Profile $changeProfile -Weights $weights # --- Collect all flagged changes --- $flaggedChanges = [System.Collections.Generic.List[PSCustomObject]]::new() $detectionProperties = @( 'TransportRuleChanges', 'ForwardingRules', 'EDiscoverySearches', 'DLPPolicyChanges', 'ExternalSharingChanges', 'TeamsExternalAccessChanges', 'BulkFileExfiltrations', 'PowerAutomateFlows', 'DefenderAlertChanges', 'AuditLogDisablements' ) foreach ($prop in $detectionProperties) { if ($changeProfile.PSObject.Properties[$prop] -and $changeProfile.$prop.Count -gt 0) { foreach ($item in $changeProfile.$prop) { $flaggedChanges.Add($item) } } } # --- Determine new threats (compare against state) --- $alertedEvents = if ($state -and $state.alertedEvents -and -not $Force) { $state.alertedEvents } else { @{} } $newThreats = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($change in $flaggedChanges) { $eventKey = "$($change.DetectionType)|$($change.Timestamp)|$($change.Actor)" $changeHash = [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::HashData( [System.Text.Encoding]::UTF8.GetBytes($eventKey) ) ).Replace('-', '').Substring(0, 16) if (-not $alertedEvents.ContainsKey($changeHash)) { $newThreats.Add($change) } } # --- Count by severity using detection type mapping --- $m365SeverityMap = @{ 'TransportRuleChange' = 'HIGH' 'ForwardingRule' = 'HIGH' 'EDiscoverySearch' = 'MEDIUM' 'DLPPolicyChange' = 'MEDIUM' 'ExternalSharingChange' = 'LOW' 'TeamsExternalAccess' = 'LOW' 'BulkFileExfiltration' = 'CRITICAL' 'PowerAutomateFlow' = 'MEDIUM' 'DefenderAlertChange' = 'HIGH' 'AuditLogDisablement' = 'CRITICAL' } $critCount = @($flaggedChanges | Where-Object { $m365SeverityMap[$_.DetectionType] -eq 'CRITICAL' }).Count $highCount = @($flaggedChanges | Where-Object { $m365SeverityMap[$_.DetectionType] -eq 'HIGH' }).Count $medCount = @($flaggedChanges | Where-Object { $m365SeverityMap[$_.DetectionType] -eq 'MEDIUM' }).Count $lowCount = @($flaggedChanges | Where-Object { $m365SeverityMap[$_.DetectionType] -eq 'LOW' }).Count # Ensure at least 1 critical if the overall threat level is CRITICAL $critCount = if ($changeProfile.ThreatLevel -eq 'CRITICAL') { [Math]::Max(1, $critCount) } else { $critCount } # --- Console report --- if (-not $Quiet) { Write-WiretapReport ` -TenantId $tenantId ` -TotalEvents $totalEvents ` -ThreatLevel $changeProfile.ThreatLevel ` -ThreatScore $changeProfile.ThreatScore ` -Indicators @($changeProfile.Indicators) ` -FlaggedChanges @($flaggedChanges) ` -NewThreats @($newThreats) ` -CriticalCount $critCount ` -HighCount $highCount ` -MediumCount $medCount ` -LowCount $lowCount if ($newThreats.Count -gt 0) { # Wrap as objects with standard fields for Write-InterceptAlert compatibility $alertObjects = @($newThreats | ForEach-Object { [PSCustomObject]@{ Email = $_.Actor ThreatLevel = $changeProfile.ThreatLevel ThreatScore = $changeProfile.ThreatScore Indicators = @("$($_.DetectionType): $($_.Description)") } }) Write-InterceptAlert -NewThreats $alertObjects } } # --- 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 = $scanStart.ToString('yyyyMMdd-HHmmss') $tenantLabel = $tenantId -replace '[^a-zA-Z0-9]', '_' $baseName = "wiretap-$tenantLabel-$timestamp" $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 WIRETAP -Message 'Generating reports' } if ($genHtml) { try { $htmlPath = Join-Path $outDir "$baseName.html" Export-WiretapReportHtml -Result $changeProfile -TenantId $tenantId ` -TotalEvents $totalEvents -DaysBack $days ` -FlaggedChanges @($flaggedChanges) -NewThreats @($newThreats) ` -OutputPath $htmlPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'HTML report' -Detail $htmlPath } } catch { Write-Warning "HTML report generation failed: $_" } } if ($genCsv -and $flaggedChanges.Count -gt 0) { try { $csvPath = Join-Path $outDir "$baseName.csv" Export-WiretapReportCsv -FlaggedChanges @($flaggedChanges) -OutputPath $csvPath if (-not $Quiet) { Write-ProgressLine -Phase REPORTING -Message 'CSV report' -Detail $csvPath } } catch { Write-Warning "CSV report generation failed: $_" } } if ($genJson) { try { $jsonPath = Join-Path $outDir "$baseName.json" Export-WiretapReportJson -Result $changeProfile -TenantId $tenantId ` -ScanId $scanId -TotalEvents $totalEvents -DaysBack $days ` -FlaggedChanges @($flaggedChanges) -NewThreats @($newThreats) ` -OutputPath $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 WIRETAP -Message "Reports saved to $outDir" } } # --- Update state --- $newAlertedEvents = @{} if ($alertedEvents) { foreach ($key in $alertedEvents.Keys) { $newAlertedEvents[$key] = $alertedEvents[$key] } } foreach ($change in $flaggedChanges) { $eventKey = "$($change.DetectionType)|$($change.Timestamp)|$($change.Actor)" $changeHash = [System.BitConverter]::ToString( [System.Security.Cryptography.SHA256]::HashData( [System.Text.Encoding]::UTF8.GetBytes($eventKey) ) ).Replace('-', '').Substring(0, 16) $newAlertedEvents[$changeHash] = @{ detectionType = $change.DetectionType actor = $change.Actor timestamp = $change.Timestamp firstDetected = [datetime]::UtcNow.ToString('o') } } $scanHistory = if ($state -and $state.scanHistory) { @($state.scanHistory) } else { @() } $scanHistory += @{ scanId = $scanId timestamp = [datetime]::UtcNow.ToString('o') daysAnalyzed = $days mode = $mode threatLevel = $changeProfile.ThreatLevel threatScore = $changeProfile.ThreatScore flaggedCount = $flaggedChanges.Count newThreats = $newThreats.Count totalEvents = $totalEvents } $newState = @{ schemaVersion = 1 watermark = [datetime]::UtcNow.ToString('o') lastScanId = $scanId alertedEvents = $newAlertedEvents scanHistory = $scanHistory } Save-TheaterState -Theater 'm365' -State $newState -ConfigPath $cfgPath # --- Complete --- $scanEnd = [datetime]::UtcNow $scanDuration = $scanEnd - $scanStart if (-not $Quiet) { Write-ProgressLine -Phase WIRETAP -Message "Wiretap sweep complete in $([Math]::Round($scanDuration.TotalSeconds, 1))s" } # --- Emit result object --- $result = [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.WiretapResult' ScanId = $scanId Timestamp = $scanStart Theater = 'M365' TenantId = $tenantId DaysAnalyzed = $days ScanMode = $mode TotalEventsAnalyzed = $totalEvents ThreatLevel = $changeProfile.ThreatLevel ThreatScore = $changeProfile.ThreatScore CriticalCount = $critCount HighCount = $highCount MediumCount = $medCount LowCount = $lowCount FlaggedChanges = @($flaggedChanges) NewThreats = @($newThreats) Indicators = @($changeProfile.Indicators) ChangeProfile = $changeProfile CsvReportPath = $csvPath HtmlReportPath = $htmlPath JsonReportPath = $jsonPath Duration = $scanDuration } return $result } |