scripts/local/exchange-purview.ps1
|
<# Raidiness — Exchange Online & Purview (local, read-only) Why local: the runner mode asks for Exchange.ManageAsApp plus the Global Reader role on a service principal. Not every customer wants that, and it is not required either: the same data can be read with the rights the administrator already has. This script runs interactively, writes nothing and produces one JSON file that you upload in Raidiness. Role: Global Reader (or higher). Get-/Export- cmdlets only. Writes: nothing in the tenant. Output: JSON for the "Exchange Online & Purview" module. Usage: .\exchange-purview.ps1 -TenantId <tenant> -RunId <run> -OutputPath .\exo.json .\exchange-purview.ps1 -TenantId <tenant> -RunId <run> -PeriodDays 90 #> # PeriodDays and MaxActivityRows are read inside a Read-Source scriptblock, # which PSScriptAnalyzer's unused-parameter rule cannot see through. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'PeriodDays', Justification = 'used inside the scc.exportActivityExplorerData scriptblock')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'MaxActivityRows', Justification = 'used inside the scc.exportActivityExplorerData scriptblock')] [CmdletBinding()] param( [Parameter(Mandatory = $true)][string] $TenantId, [Parameter(Mandatory = $true)][string] $RunId, [string] $OutputPath = ".\raidiness-exchange-purview.json", [ValidateSet(30, 90)][int] $PeriodDays = 30, # Activity Explorer paginates; this cap keeps the upload manageable. [int] $MaxActivityRows = 5000, [switch] $HashUpns ) $ErrorActionPreference = "Stop" # Same helper as the other module scripts: with -HashUpns every identity this # script touches (mailbox names, policy authors, reviewers) leaves as a # truncated SHA-256 instead of an address. The mappers only count these # fields, so the measurements are identical either way. function Protect-Upn { param([string] $Value) if (-not $HashUpns -or [string]::IsNullOrWhiteSpace($Value)) { return $Value } $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value.ToLowerInvariant()) $sha = [System.Security.Cryptography.SHA256]::Create() return (($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 16) } Write-Host "Raidiness — Exchange Online & Purview, read-only. Role: Global Reader." -ForegroundColor Cyan Write-Host "Nothing will be changed in the tenant." -ForegroundColor Cyan if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) { throw "The ExchangeOnlineManagement module is missing. Install it first: Install-Module ExchangeOnlineManagement -Scope CurrentUser" } Import-Module ExchangeOnlineManagement <# A step that fails (missing licence, missing role) must not take the rest down with it: the report is more accurate with ten of the thirteen sources than with none. Whatever did not succeed is skipped and later shows up as 'Not measured', never as an unwarranted zero. #> $results = [ordered]@{} function Read-Source { param([string] $Key, [scriptblock] $Read) try { $results[$Key] = & $Read Write-Host " ✓ $Key" -ForegroundColor Green } catch { Write-Host " ! $Key skipped: $($_.Exception.Message)" -ForegroundColor Yellow } } # --- Exchange Online -------------------------------------------------------- Connect-ExchangeOnline -ShowBanner:$false Read-Source "exo.recipients" { $byType = Get-EXORecipient -ResultSize Unlimited -Properties RecipientTypeDetails | Group-Object -Property RecipientTypeDetails | Select-Object -Property Name, Count [ordered]@{ byType = @($byType) total = ($byType | Measure-Object -Property Count -Sum).Sum } } Read-Source "exo.adminAuditLogConfig" { Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled, AdminAuditLogEnabled, AdminAuditLogAgeLimit } Read-Source "exo.organizationConfig" { Get-OrganizationConfig | Select-Object DisplayName, IsDehydrated, AuditDisabled, DefaultPublicFolderAgeLimit } Read-Source "exo.mailboxAuditBypass" { $bypassed = Get-MailboxAuditBypassAssociation -ResultSize Unlimited | Where-Object { $_.AuditBypassEnabled -eq $true } [ordered]@{ count = @($bypassed).Count samples = @($bypassed | Select-Object -First 10 -Property Name | ForEach-Object { $_.Name = Protect-Upn $_.Name; $_ }) } } Read-Source "exo.mailboxesAuditDisabled" { $disabled = Get-EXOMailbox -ResultSize Unlimited -Properties AuditEnabled | Where-Object { $_.AuditEnabled -eq $false } $total = Get-EXOMailbox -ResultSize Unlimited | Measure-Object [ordered]@{ disabledCount = @($disabled).Count totalCount = $total.Count } } Disconnect-ExchangeOnline -Confirm:$false | Out-Null # --- Purview ---------------------------------------------------------------- Connect-IPPSSession -ShowBanner:$false # The field lists below are identical to those of the runner script # (apps/psrunner/scripts/exchange-purview/scc.*.ps1). The same mapper processes # both, so a missing field here does not yield an 'unknown' but a WRONG number: # without ContentContainsSensitiveInformation every tenant using this script # would be told that its DLP rules search for nothing. # scripts/local-bundle.test.ts compares the two lists and fails on drift. Read-Source "scc.dlpCompliancePolicies" { [ordered]@{ collectedAt = (Get-Date).ToUniversalTime().ToString('o') policies = @(Get-DlpCompliancePolicy | Select-Object Name, Mode, Enabled, Workload, ExchangeLocation, SharePointLocation, OneDriveLocation, TeamsLocation, EndpointDlpLocation, PowerBIDlpLocation, ThirdPartyAppDlpLocation, EnforcementPlanes, CreatedBy, WhenCreated, WhenChanged | ForEach-Object { $_.CreatedBy = Protect-Upn $_.CreatedBy; $_ }) rules = @(Get-DlpComplianceRule | Select-Object Name, Policy, Disabled, GenerateAlert, GenerateIncidentReport, BlockAccess, BlockAccessScope, NotifyUser, NotifyPolicyTipDisplayOption, NotifyEndpointUser, ReportSeverityLevel, ContentContainsSensitiveInformation, WhenCreated) } } Read-Source "scc.dlpSensitiveInformationTypes" { [ordered]@{ types = @(Get-DlpSensitiveInformationType | Select-Object Name, Id, Publisher, RulePackId, Type) } } Read-Source "scc.protectionAlerts" { @(Get-ProtectionAlert | Select-Object Name, Category, Disabled, Severity, ThreatType, NotifyUser) } Read-Source "scc.labels" { @(Get-Label | Select-Object Name, DisplayName, Guid, ParentId, Priority, ContentType, Disabled, Workload, EncryptionEnabled, EncryptionProtectionType, EncryptionRightsDefinitions, EncryptionDoNotForward, EncryptionEncryptOnly, WhenChanged) } Read-Source "scc.labelPolicies" { @(Get-LabelPolicy | Select-Object Name, Mode, Enabled, Labels, Settings, ExchangeLocation, ModernGroupLocation, WhenChanged) } Read-Source "scc.autoSensitivityLabelPolicies" { @(Get-AutoSensitivityLabelPolicy | Select-Object Name, Mode, Enabled, ApplySensitivityLabel, SharePointLocation, OneDriveLocation, ExchangeLocation, WhenChanged) } Read-Source "scc.exportActivityExplorerData" { $end = (Get-Date).ToUniversalTime() $start = $end.AddDays(-$PeriodDays) $rows = @() $cookie = $null do { $page = Export-ActivityExplorerData -StartTime $start -EndTime $end ` -Activity 'LabelApplied' -PageSize 500 -PageCookie $cookie $rows += @($page.ResultData) $cookie = $page.LastPage ? $null : $page.WaterMark } while ($cookie -and $rows.Count -lt $MaxActivityRows) [ordered]@{ periodDays = $PeriodDays total = @($rows).Count truncated = @($rows).Count -ge $MaxActivityRows byLabel = @($rows | Group-Object -Property SensitivityLabelId | Select-Object Name, Count) } } Read-Source "scc.retentionCompliancePolicies" { @(Get-RetentionCompliancePolicy -DistributionDetail | Select-Object Name, Enabled, Mode, Workload, TeamsChatLocation, TeamsChannelLocation, ExchangeLocation, SharePointLocation, OneDriveLocation, ModernGroupLocation, RestrictiveRetention, WhenChanged) } Read-Source "scc.appRetentionCompliancePolicies" { @(Get-AppRetentionCompliancePolicy | Select-Object Name, Enabled, Mode, Workload, PolicyRBACScopes, AdaptiveScopeLocation, RestrictiveRetention, WhenChanged) } Read-Source "scc.supervisoryReviewPolicies" { @(Get-SupervisoryReviewPolicyV2 | Select-Object Name, Enabled, Reviewers, WhenChanged | ForEach-Object { $_.Reviewers = @($_.Reviewers | ForEach-Object { Protect-Upn $_ }); $_ }) } Read-Source "scc.insiderRiskPolicies" { @(Get-InsiderRiskPolicy | Select-Object Name, Enabled, InsiderRiskScenario, Status, WhenChanged) } Disconnect-ExchangeOnline -Confirm:$false | Out-Null $payload = [ordered]@{ raidiness = [ordered]@{ version = "1.0" module = "exchange-purview" tenantId = $TenantId runId = $RunId generatedAt = (Get-Date).ToUniversalTime().ToString("o") # Honest about what left the machine: mailbox names, policy authors and # reviewers are hashed only when -HashUpns was given. upnsHashed = [bool] $HashUpns } results = $results } $payload | ConvertTo-Json -Depth 8 | Out-File -FilePath $OutputPath -Encoding utf8 Write-Host "Done. Upload $OutputPath in Raidiness under the Exchange Online & Purview module." -ForegroundColor Green |