diff --git a/.gitattributes b/.gitattributes index 2162ff5..88a7fb4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,17 @@ # Language statistics: the guides are documentation, not code. package/docs/** linguist-documentation +package/** whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol + +# Authenticode and the manifest authenticate exact raw bytes, on every checkout. +package/Invoke-PurviewDlpReport.ps1 -text +package/PurviewDlpReport.psm1 -text +package/PurviewDlpReport.psd1 -text +package/Test-PackageIntegrity.ps1 -text +package/PackageIntegrity.psd1 -text +package/src/PurviewDlpReport.Engine.cs -text +package/templates/Report.template.html -text +package/docs/PurviewDlpReport-Guide.html -text +package/docs/PurviewDlpReport-UserGuide.html -text +package/README.md -text +package/THIRD-PARTY-NOTICES.md -text +package/lib/sqlite/**/*.dll -text diff --git a/.github/ci.json b/.github/ci.json index 3537edc..1a083ec 100644 --- a/.github/ci.json +++ b/.github/ci.json @@ -3,7 +3,8 @@ "testMode": "pester", "testEntry": null, "tests": [ - "tests/PurviewDlpReport.Tests.ps1" + "tests/PurviewDlpReport.Tests.ps1", + "tests/PackageIntegrity.Tests.ps1" ], "packageBuilder": "tools/New-DlpPackage.ps1", "packageEntry": "Invoke-PurviewDlpReport.ps1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d047ea..ea9e77e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes are listed here. Versions follow MAJOR.MINOR.PATCH (see the guide, Annex E). Author: Nicolas Fabert. +## [2.1.2] — 2026-10-13 + +### Added +- SHA256 owner Authenticode signatures with RFC3161 timestamps on the launcher, module, module metadata, integrity verifier and stable-file manifest. Fail-closed verification authenticates sixteen mandatory raw-file hashes before runtime loading, warm initialization and report template use. Original SQLite vendor bytes and authorship are preserved. +- Exact coverage/completeness and provisional durations in `report-metadata.json`, including CSV-only reports; compatible business CSV columns are unchanged. +- Optional company branding and explicit opt-in tenant/environment display (off by default), independent of the fixed Nicolas Fabert author. + +### Fixed +- Bind databases to the tenant verified by an authenticated collection; reject mismatched tenants and retain explicit unverified-legacy-history warnings. +- Distinguish exact second-level source-boundary observations from unexpected timestamps, retaining records and reporting bounded diagnostics without duplicate insertion. +- Decide completeness from exact gaps and failed refreshes, not rounded percentages; explain Status as a read-only queried-time snapshot and recent data as provisional. +- Sort HTML chronology using UTC and display offsets/UTC details across daylight-saving changes. +- Validate source retention (1–30 days), settling and storage/backfill consistency; reject configurable author overrides. + +### Changed +- Compile authenticated C# in memory once per process rather than trusting unsigned `bin` engine caches. Startup recompilation and recurring checks add overhead; this release does not claim broad load or production validation. +- Preserve existing runtime paths, README and both HTML guides in packages. The same trusted manifest works for source and distribution; only explicitly named, non-runtime repository documents are unhashed. Configuration stays editable and is sanitized by the existing packager, which now verifies authenticity before and after copying. +- Keep the existing required PowerShell 7 validation, advisory analysis/CodeQL and pinned Actions. PowerShell 7.4+ is the business requirement; Windows PowerShell 5.1 is signature inspection only. +- Preserve the canonical personal-project disclaimer in README, runtime README and launcher help. + ## [2.1.1] — 2026-09-30 First public release. diff --git a/README.md b/README.md index 2ba1755..29824fd 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,14 @@ notepad .\config\PurviewDlpReport.config.psd1 # TenantId, PolicyId, RuleId, Activity Explorer keeps 30 days of data: schedule the daily collection. The `package` folder of this repository holds exactly the files needed to run Purview DLP Report, with both guides. The zip of each [release](https://github.com/Nico77600/PurviewDlpReport/releases) contains the same run-time files with the HTML guides; `.\tools\New-DlpPackage.ps1` builds that zip content from the repository. +## Signed packages and integrity (2.1.2) + +The launcher, module, module metadata, integrity verifier and integrity manifest carry Nicolas Fabert's SHA256 Authenticode signatures with RFC3161 timestamps. The trusted owner certificate thumbprint is `0F28E442889F17B5F4D251E8F96E7CC6E2C9C6D6`. Automatic checks block loading on a missing manifest, invalid signature, absent timestamp, changed protected bytes, extra stable code or a protected reparse point. See the [developer guide](package/docs/PurviewDlpReport-Guide.md#signed-package-integrity) for the exact boundary, trust requirements and source-change procedure. + +Only `config\PurviewDlpReport.config.psd1` is editable. Stable runtime files, the report template, bundled DLLs, README and both distributed HTML guides are authenticated. SQLite binaries keep their original vendor bytes and authorship. Verified C# compiles in memory once per process; startup and warm integrity checks have overhead, and old `bin` caches are never loaded. Windows PowerShell 5.1 can inspect signatures but cannot run the tool; business runtime requires PowerShell 7.4 or later. + +Coverage is queried time, not an event-delivery percentage. Exact gaps and failed refreshes remain incomplete even when a rounded percentage says 100%; `report-metadata.json` records that status. HTML sorting uses UTC across daylight-saving changes. Company branding is separate from the fixed author; tenant/environment display is off by default and requires administrator opt-in. A database is bound to its first authenticated tenant, with explicit warnings for unverified legacy history. + ## Documentation | Guide | Content | diff --git a/package/Invoke-PurviewDlpReport.ps1 b/package/Invoke-PurviewDlpReport.ps1 index 8d72fcf..68b8adc 100644 --- a/package/Invoke-PurviewDlpReport.ps1 +++ b/package/Invoke-PurviewDlpReport.ps1 @@ -1,337 +1,537 @@ -#Requires -Version 7.4 - -<# -.SYNOPSIS - Purview DLP Report - Exchange Online messages matched by a Microsoft Purview DLP rule - (by default: messages sent to more than 25 recipients). One row per Message ID. - -.DESCRIPTION - The tool works in two stages: - - 1. COLLECT Activity Explorer (Export-ActivityExplorerData) -> local SQLite database. - Activity Explorer keeps only 30 days: collect at least once a day - (scheduled task, -Mode Collect). The database keeps the history. - 2. REPORT SQLite database -> CSV + HTML files in a local folder. - Before writing, the missing part of the period (if any) is collected. - - Everything is set in config\PurviewDlpReport.config.psd1; the command line only - chooses what to do. The tool is read-only for the tenant: it never sends e-mail and - never changes any Microsoft 365 setting. - -.PARAMETER Mode - Report (default) Collects what is missing for the period, then writes the report. - Collect Collects the last days into the database (scheduled task). No report. - Status Shows what the database contains, day by day. No connection. - -.PARAMETER Range - Period of the report (Report mode). Default: Report.DefaultRange in the configuration. - Last24Hours, Last7Days, Last30Days : rolling windows ending now - PreviousMonth : the previous calendar month - Month -Month 2026-08 : a calendar month - Day -Date 2026-09-28 : a calendar day - Custom -Start '2026-09-01 08:00' -End '2026-09-03' (report time zone unless an offset is given) - -.PARAMETER ConfigPath - Configuration file. Default: config\PurviewDlpReport.config.psd1 next to this script. - -.PARAMETER IncludeRecipientDetails - Overrides Report.IncludeRecipientDetails for this execution. - -IncludeRecipientDetails : recipient addresses in the files - -IncludeRecipientDetails:$false : recipient count only (smaller files) - -.PARAMETER SplitBy - Overrides Report.SplitBy (Rows, Day or Week) for this execution. - -.PARAMETER MaxRowsPerFile - Overrides Report.MaxRowsPerFile for this execution. - -.PARAMETER OutputPath - Overrides Report.OutputPath for this execution. - -.PARAMETER NoCollect - Report mode: do not connect to Microsoft 365; use only the data already in the database. - -.EXAMPLE - .\Invoke-PurviewDlpReport.ps1 - Report of the default period (last 7 days), collecting what is missing first. - -.EXAMPLE - .\Invoke-PurviewDlpReport.ps1 -Range PreviousMonth -IncludeRecipientDetails:$false - Report of the previous month with the recipient count only. - -.EXAMPLE - .\Invoke-PurviewDlpReport.ps1 -Mode Collect - Daily collection (scheduled task). - -.EXAMPLE - .\Invoke-PurviewDlpReport.ps1 -Mode Status - What is in the database, day by day, and what is missing. - -.NOTES - Author : Nicolas Fabert - Version : 2.1.1 - Exit codes : 0 = success, 1 = failure, 2 = finished but incomplete (see the summary). - Documentation : docs\PurviewDlpReport-Guide.md (or .html) - - This Script is a Personal project. - It's provided "AS-IS". It's not an official Microsoft product so no support can be expected from Microsoft. - - As any scripts you must read carefully the documentation and test it first in a Test environment before any run in Production. -#> -[CmdletBinding()] -param( - [ValidateSet('Report', 'Collect', 'Status')] - [string]$Mode = 'Report', - - [ValidateSet('Last24Hours', 'Last7Days', 'Last30Days', 'PreviousMonth', 'Month', 'Day', 'Custom')] - [string]$Range, - [string]$Month, - [string]$Date, - [string]$Start, - [string]$End, - - [string]$ConfigPath = (Join-Path $PSScriptRoot 'config\PurviewDlpReport.config.psd1'), - [switch]$IncludeRecipientDetails, - [ValidateSet('Rows', 'Day', 'Week')] - [string]$SplitBy, - [ValidateRange(1000, 1048575)] - [int]$MaxRowsPerFile, - [string]$OutputPath, - [switch]$NoCollect -) - -$ErrorActionPreference = 'Stop' -[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) -# Numbers and dates are displayed the same way on every server (1,234.5), whatever the regional settings. -$previousCulture = [Threading.Thread]::CurrentThread.CurrentCulture -[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo('en-US') -$clock = [Diagnostics.Stopwatch]::StartNew() -$exitCode = 1 -$runStatus = 'Failed' -$store = $null; $connection = $null; $lock = $null; $runId = $null; $details = [ordered]@{} - -try { - # do { } while ($false): 'break' ends the execution early; the finally block always runs. - do { - Import-Module (Join-Path $PSScriptRoot 'PurviewDlpReport.psd1') -Force - - # --------------------------------------------------------------------------------- - # Configuration file, then the command-line overrides. - # --------------------------------------------------------------------------------- - $settings = Import-DlpConfiguration -Path $ConfigPath -Root $PSScriptRoot - if ($PSBoundParameters.ContainsKey('IncludeRecipientDetails')) { $settings.Report.IncludeRecipientDetails = [bool]$IncludeRecipientDetails } - if ($SplitBy) { $settings.Report.SplitBy = $SplitBy } - if ($MaxRowsPerFile) { $settings.Report.MaxRowsPerFile = $MaxRowsPerFile } - if ($OutputPath) { $settings.Report.OutputPath = [IO.Path]::GetFullPath($OutputPath, (Get-Location).Path) } - if ($Mode -eq 'Report' -and -not $Range) { $Range = $settings.Report.DefaultRange } - $zone = $settings.Zone - - $logPath = Start-DlpLog -Directory $settings.Logging.Path -RetentionDays $settings.Logging.RetentionDays - Initialize-DlpEngine -Root $PSScriptRoot - - $collecting = $Mode -eq 'Collect' -or ($Mode -eq 'Report' -and -not $NoCollect) - $totalSteps = switch ($Mode) { 'Status' { 2 } 'Collect' { 4 } default { 5 } } - $period = $null - if ($Mode -eq 'Report') { - $period = Resolve-DlpPeriod -Range $Range -Month $Month -Date $Date -Start $Start -End $End -Zone $zone - } - $dot = [char]0x00B7 - $modeText = $Mode + $(if ($period) { " $dot $Range" } elseif ($Mode -eq 'Collect') { " $dot last $($settings.Collection.BackfillDays) days" } else { '' }) - $banner = [ordered]@{ Mode = @('Info', $modeText) } - if ($period) { $banner['Period'] = @('Calendar', "$(Format-DlpRange $period.StartMs $period.EndMs $zone) ($($settings.Report.TimeZone), end excluded)") } - $ruleText = if ($settings.Target.RuleName) { $settings.Target.RuleName + $(if ($settings.Target.PolicyName) { " ($($settings.Target.PolicyName))" }) } else { "rule $($settings.Target.RuleId)" } - $banner['Rule'] = @('Target', $ruleText) - $banner['Database'] = @('Database', $settings.Storage.DatabasePath) - $banner['Log'] = @('Log', $logPath) - Write-DlpBanner -Title 'Purview DLP Report' -Subtitle "Exchange Online DLP matches $dot one row per Message ID" -Details $banner - - # --------------------------------------------------------------------------------- - # Step 1 - Database (and lock when this execution collects). - # --------------------------------------------------------------------------------- - Write-DlpStep 1 $totalSteps 'Opening the database' -Icon Database - if ($Mode -eq 'Status' -and -not (Test-Path -LiteralPath $settings.Storage.DatabasePath)) { - # New installation: nothing collected yet. The configuration is valid and the engine is built. - Write-DlpItem Info 'No database yet: the first collection (-Mode Collect) or the first report creates it.' - Write-DlpSummary -Title 'Ready for the first collection' -Values ([ordered]@{ - Config = @('Ok', 'valid') - Engine = @('Ok', 'ready') - Database = @('Database', 'none yet') - Next = @('Info', '.\Invoke-PurviewDlpReport.ps1 -Mode Collect') - }) -Status Ok - $exitCode = 0 - $runStatus = 'Completed' - break - } - if ($Mode -eq 'Status') { - $store = Open-DlpStore -Settings $settings -ReadOnly - } else { - if ($collecting) { $lock = Enter-DlpLock -Path ($settings.Storage.DatabasePath + '.lock') } - $store = Open-DlpStore -Settings $settings - if ($collecting) { - $closed = $store.CloseAbandonedWork() - if ($closed) { Write-DlpItem Warn "$closed collection window(s) left unfinished by an interrupted execution will be collected again." } - } - $runId = $store.StartRun($Mode, $null, [Environment]::MachineName, (Get-Module PurviewDlpReport).Version.ToString(), $null) - } - $targetId = if ($Mode -eq 'Status') { $store.FindTarget($settings.Target.PolicyId, $settings.Target.RuleId) } - else { $store.GetOrCreateTarget($settings.Target.PolicyId, $settings.Target.RuleId, $settings.Target.PolicyName, $settings.Target.RuleName) } - $stats = $store.GetStatistics($targetId) - Write-DlpItem Ok ("{0} events {3} {1} messages {3} {2}" -f (Format-DlpNumber $stats.Events), (Format-DlpNumber $stats.Messages), (Format-DlpBytes $stats.FileBytes), [char]0x00B7) - - # --------------------------------------------------------------------------------- - # Status mode: day-by-day view, then stop. - # --------------------------------------------------------------------------------- - if ($Mode -eq 'Status') { - Write-DlpStep 2 $totalSteps 'Collected data, day by day' -Icon Calendar - Show-DlpStatus -Store $store -TargetId $targetId -Settings $settings - $expiring = @(if ($targetId) { Get-DlpExpiringGaps -Store $store -TargetId $targetId -Settings $settings }) - foreach ($gap in $expiring) { Write-DlpItem Warn ("Missing {0}: Activity Explorer keeps it for about {1} more day(s). Run -Mode Collect." -f (Format-DlpRange $gap.Start $gap.End $zone), $gap.DaysLeft) } - $exitCode = 0 - $runStatus = 'Completed' - break - } - - # --------------------------------------------------------------------------------- - # Step 2 - What must be collected? - # --------------------------------------------------------------------------------- - $nowMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - if ($Mode -eq 'Collect') { - Write-DlpStep 2 $totalSteps 'Planning the collection' -Icon Plan - $windowStart = [Math]::Max($nowMs - [long]$settings.Collection.BackfillDays * 86400000, $nowMs - [long]$settings.Collection.SourceRetentionDays * 86400000 + 3600000) - $plan = Get-DlpCollectionPlan -Store $store -TargetId $targetId -StartMs $windowStart -EndMs $nowMs -Settings $settings -NowMs $nowMs - } else { - Write-DlpStep 2 $totalSteps 'Checking the data available for the period' -Icon Plan - $plan = Get-DlpCollectionPlan -Store $store -TargetId $targetId -StartMs $period.StartMs -EndMs $period.EndMs -Settings $settings -NowMs $nowMs - } - $slices = $plan.Slices - if ($slices.Count) { - $refresh = if ($plan.RefreshMs -gt 0) { " (of which {0} already collected, refreshed to catch late events)" -f (Format-DlpDuration ($plan.RefreshMs / 1000)) } else { '' } - Write-DlpItem Info ("To collect: {0} window(s), {1} in total{2}" -f $slices.Count, (Format-DlpDuration ($plan.CollectableMs / 1000)), $refresh) - } else { - Write-DlpItem Ok 'Everything is already in the database.' - } - foreach ($gap in $plan.Unrecoverable) { - Write-DlpItem Warn ("Not in the database and older than the {0} days kept by Activity Explorer: {1}" -f $settings.Collection.SourceRetentionDays, (Format-DlpRange $gap.Start $gap.End $zone)) - } - if ($Mode -eq 'Report' -and $NoCollect -and $plan.NewMs -gt 0) { - Write-DlpItem Warn '-NoCollect: the missing part will not be collected; the report will be incomplete.' - } elseif ($Mode -eq 'Report' -and $NoCollect -and $plan.RefreshMs -gt 0) { - Write-DlpItem Info '-NoCollect: recent data is not refreshed; events that reached Activity Explorer late may be missing.' - } - - # --------------------------------------------------------------------------------- - # Steps 3 and 4 - Connection and collection (only when something is missing). - # --------------------------------------------------------------------------------- - $collection = $null - if ($collecting -and $slices.Count) { - Write-DlpStep 3 $totalSteps 'Connecting to Security & Compliance PowerShell' -Icon Key - if ($settings.Authentication.Mode -eq 'Interactive') { Write-DlpItem Info 'A sign-in window may open: sign in with the administrator account.' -Icon People } - $connectClock = [Diagnostics.Stopwatch]::StartNew() - $connection = Connect-DlpActivityExplorer -Settings $settings - $store.UpdateRunAccount($runId, $connection.Account) - Write-DlpItem Ok ("{0} {3} {1} mode {3} tenant verified {3} {2}" -f $connection.Account, $connection.Mode.ToLowerInvariant(), (Format-DlpDuration $connectClock.Elapsed.TotalSeconds), [char]0x00B7) - - Write-DlpStep 4 $totalSteps 'Collecting Activity Explorer data' -Icon Download - $collection = Invoke-DlpCollection -Store $store -RunId $runId -TargetId $targetId -Settings $settings -Slices $slices -Exporter $connection.Exporter - $details['Collection'] = $collection - if ($collection.Quarantined) { Write-DlpItem Warn ("{0} record(s) could not be read and were set aside (table 'quarantine'): {1}" -f $collection.Quarantined, (($collection.QuarantineReasons.GetEnumerator() | ForEach-Object { "$($_.Key) x$($_.Value)" }) -join ', ')) } - if ($collection.Conflicts) { Write-DlpItem Info ("{0} event(s) had a sender, subject or recipient list different from an earlier event of the same message; the first values are kept." -f $collection.Conflicts) } - if ($collection.Completed) { - Write-DlpItem Ok ("Collection complete: {0} events received, {1} new, in {2} (Activity Explorer: {3})" -f (Format-DlpNumber $collection.Records), (Format-DlpNumber $collection.NewEvents), (Format-DlpDuration $collection.Seconds), (Format-DlpDuration $collection.SourceSeconds)) - } else { - Write-DlpItem Fail ("Collection stopped: {0}" -f $collection.Error) - Write-DlpItem Info 'What was received is kept. Run the same command again to continue from the missing windows.' - } - } elseif ($collecting) { - Write-DlpStep 3 $totalSteps 'Connecting to Security & Compliance PowerShell' -Icon Key - Write-DlpItem Skip 'Not needed.' - Write-DlpStep 4 $totalSteps 'Collecting Activity Explorer data' -Icon Download - Write-DlpItem Skip 'Nothing to collect.' - } else { - Write-DlpStep 3 $totalSteps 'Connecting to Security & Compliance PowerShell' -Icon Key - Write-DlpItem Skip '-NoCollect: no connection.' - Write-DlpStep 4 $totalSteps 'Collecting Activity Explorer data' -Icon Download - Write-DlpItem Skip '-NoCollect: the report uses the data already collected.' - } - if ($collecting) { - $purge = Invoke-DlpRetention -Store $store -Settings $settings - if ($purge -and $purge.Events) { Write-DlpItem Info ("Retention ({0} days): {1} old events deleted." -f $settings.Storage.RetentionDays, (Format-DlpNumber $purge.Events)) } - } - $collectionFailed = $collection -and -not $collection.Completed - - # --------------------------------------------------------------------------------- - # Collect mode ends here. - # --------------------------------------------------------------------------------- - if ($Mode -eq 'Collect') { - $expiring = @(Get-DlpExpiringGaps -Store $store -TargetId $targetId -Settings $settings) - foreach ($gap in $expiring) { Write-DlpItem Warn ("Still missing {0}: collectable for about {1} more day(s)." -f (Format-DlpRange $gap.Start $gap.End $zone), $gap.DaysLeft) } - $stats = $store.GetStatistics($targetId) - $summary = [ordered]@{ - 'Result' = @($(if ($collectionFailed) { 'Fail' } else { 'Ok' }), $(if ($collectionFailed) { 'Incomplete - run again to continue' } else { 'Collection complete' })) - 'Events' = @('Download', $(if ($collection) { '{0} received, {1} new' -f (Format-DlpNumber $collection.Records), (Format-DlpNumber $collection.NewEvents) } else { 'nothing to collect' })) - 'Database' = @('Database', ('{0} events {3} {1} messages {3} {2}' -f (Format-DlpNumber $stats.Events), (Format-DlpNumber $stats.Messages), (Format-DlpBytes $stats.FileBytes), [char]0x00B7)) - 'Duration' = @('Clock', (Format-DlpDuration $clock.Elapsed.TotalSeconds)) - 'Log' = @('Log', $logPath) - } - $status = if ($collectionFailed) { 'Fail' } elseif ($expiring.Count) { 'Warn' } else { 'Ok' } - Write-DlpSummary -Title $(if ($collectionFailed) { 'Collection incomplete' } else { 'Collection finished' }) -Values $summary -Status $status - $exitCode = if ($collectionFailed) { 1 } else { 0 } - $runStatus = if ($collectionFailed) { 'Incomplete' } else { 'Completed' } - break - } - - # --------------------------------------------------------------------------------- - # Step 5 - Report files. - # --------------------------------------------------------------------------------- - Write-DlpStep 5 $totalSteps 'Writing the report' -Icon Report - $coverage = Get-DlpCoverage -Store $store -TargetId $targetId -StartMs $period.StartMs -EndMs $period.EndMs - $reportClock = [Diagnostics.Stopwatch]::StartNew() - $report = New-DlpReport -Store $store -TargetId $targetId -Settings $settings -Period $period -Coverage $coverage - foreach ($file in $report.Result.Files) { - Write-DlpItem Ok ("{0,-4} {1} {2} messages {4} {3}" -f $file.Kind, (Split-Path $file.Path -Leaf), (Format-DlpNumber $file.Rows), (Format-DlpBytes $file.Bytes), [char]0x00B7) -Icon File - } - Write-DlpItem Info ("Written in {0}" -f (Format-DlpDuration $reportClock.Elapsed.TotalSeconds)) -Icon Clock - $r = $report.Result - if ($r.MessagesWithoutCount) { Write-DlpItem Info ("{0} message(s) without a reliable recipient count (column left empty)." -f (Format-DlpNumber $r.MessagesWithoutCount)) } - $complete = $coverage.Percent -ge 100 -and -not $collectionFailed - $summary = [ordered]@{ - 'Period' = @('Calendar', "$(Format-DlpRange $period.StartMs $period.EndMs $zone) ($($settings.Report.TimeZone))") - 'Messages' = @('Mail', ('{0} unique messages {2} {1} senders' -f (Format-DlpNumber $r.Messages), (Format-DlpNumber $r.Senders), [char]0x00B7)) - 'Coverage' = @($(if ($coverage.Percent -ge 100) { 'Chart' } else { 'Warn' }), $(if ($coverage.Percent -ge 100) { '100% of the period' } else { '{0}% of the period - missing: {1}' -f $coverage.Percent, ((@($coverage.Gaps) | Select-Object -First 3 | ForEach-Object { Format-DlpRange $_.Start $_.End $zone }) -join ', ') })) - 'Files' = @('File', ('{0} file(s) {2} recipients {1}' -f $r.Files.Count, $(if ($settings.Report.IncludeRecipientDetails) { 'listed' } else { 'counted only' }), [char]0x00B7)) - 'Folder' = @('Folder', $report.Directory) - 'Duration' = @('Clock', (Format-DlpDuration $clock.Elapsed.TotalSeconds)) - 'Log' = @('Log', $logPath) - } - Write-DlpSummary -Title $(if ($complete) { 'Report ready' } else { 'Report written, but INCOMPLETE' }) -Values $summary -Status $(if ($complete) { 'Ok' } else { 'Warn' }) - $details['Report'] = [ordered]@{ Directory = $report.Directory; Messages = $r.Messages; Files = @($r.Files | ForEach-Object { $_.Path }); CoveragePercent = $coverage.Percent } - $exitCode = if ($complete) { 0 } else { 2 } - $runStatus = if ($complete) { 'Completed' } else { 'Incomplete' } - } while ($false) -} -catch { - $message = $_.Exception.Message - Write-Host '' - if (Get-Command Write-DlpSummary -ErrorAction SilentlyContinue) { - Write-DlpSummary -Title 'Execution stopped' -Values ([ordered]@{ Error = @('Fail', $message); Duration = @('Clock', (Format-DlpDuration $clock.Elapsed.TotalSeconds)) }) -Status Fail - } else { - Write-Host " [ERROR] $message" - } - if (Get-Command Write-DlpLog -ErrorAction SilentlyContinue) { Write-DlpLog -Level 'ERROR' -Message ($message + "`n" + $_.ScriptStackTrace) } - $details['Error'] = $message - $exitCode = 1 -} -finally { - if ($store -and $runId) { - try { $store.FinishRun($runId, $runStatus, ($details | ConvertTo-Json -Depth 6 -Compress)) } catch { } - } - if ($store) { $store.Dispose() } - if (Get-Command Disconnect-DlpActivityExplorer -ErrorAction SilentlyContinue) { Disconnect-DlpActivityExplorer -Connection $connection } - if (Get-Command Exit-DlpLock -ErrorAction SilentlyContinue) { Exit-DlpLock -Lock $lock } - if (Get-Command Write-DlpLog -ErrorAction SilentlyContinue) { - Write-DlpLog -Level 'INFO' -Message ("Exit code {0} after {1:0.0} s" -f $exitCode, $clock.Elapsed.TotalSeconds) - Stop-DlpLog - } - [Threading.Thread]::CurrentThread.CurrentCulture = $previousCulture -} -exit $exitCode +#Requires -Version 7.4 + +<# +.SYNOPSIS + Purview DLP Report - Exchange Online messages matched by a Microsoft Purview DLP rule + (by default: messages sent to more than 25 recipients). One row per Message ID. + +.DESCRIPTION + The tool works in two stages: + + 1. COLLECT Activity Explorer (Export-ActivityExplorerData) -> local SQLite database. + Activity Explorer keeps only 30 days: collect at least once a day + (scheduled task, -Mode Collect). The database keeps the history. + 2. REPORT SQLite database -> CSV + HTML files in a local folder. + Before writing, the missing part of the period (if any) is collected. + + Everything is set in config\PurviewDlpReport.config.psd1; the command line only + chooses what to do. The tool is read-only for the tenant: it never sends e-mail and + never changes any Microsoft 365 setting. + +.PARAMETER Mode + Report (default) Collects what is missing for the period, then writes the report. + Collect Collects the last days into the database (scheduled task). No report. + Status Shows what the database contains, day by day. No connection. + +.PARAMETER Range + Period of the report (Report mode). Default: Report.DefaultRange in the configuration. + Last24Hours, Last7Days, Last30Days : rolling windows ending now + PreviousMonth : the previous calendar month + Month -Month 2026-08 : a calendar month + Day -Date 2026-09-28 : a calendar day + Custom -Start '2026-09-01 08:00' -End '2026-09-03' (report time zone unless an offset is given) + +.PARAMETER ConfigPath + Configuration file. Default: config\PurviewDlpReport.config.psd1 next to this script. + +.PARAMETER IncludeRecipientDetails + Overrides Report.IncludeRecipientDetails for this execution. + -IncludeRecipientDetails : recipient addresses in the files + -IncludeRecipientDetails:$false : recipient count only (smaller files) + +.PARAMETER SplitBy + Overrides Report.SplitBy (Rows, Day or Week) for this execution. + +.PARAMETER MaxRowsPerFile + Overrides Report.MaxRowsPerFile for this execution. + +.PARAMETER OutputPath + Overrides Report.OutputPath for this execution. + +.PARAMETER NoCollect + Report mode: do not connect to Microsoft 365; use only the data already in the database. + +.EXAMPLE + .\Invoke-PurviewDlpReport.ps1 + Report of the default period (last 7 days), collecting what is missing first. + +.EXAMPLE + .\Invoke-PurviewDlpReport.ps1 -Range PreviousMonth -IncludeRecipientDetails:$false + Report of the previous month with the recipient count only. + +.EXAMPLE + .\Invoke-PurviewDlpReport.ps1 -Mode Collect + Daily collection (scheduled task). + +.EXAMPLE + .\Invoke-PurviewDlpReport.ps1 -Mode Status + What is in the database, day by day, and what is missing. + +.NOTES + Author : Nicolas Fabert + Version : 2.1.2 + Exit codes : 0 = success, 1 = failure, 2 = finished but incomplete (see the summary). + Documentation : docs\PurviewDlpReport-Guide.md (or .html) + + This Script is a Personal project. + It's provided "AS-IS". It's not an official Microsoft product so no support can be expected from Microsoft. + + As any scripts you must read carefully the documentation and test it first in a Test environment before any run in Production. +#> +[CmdletBinding()] +param( + [ValidateSet('Report', 'Collect', 'Status')] + [string]$Mode = 'Report', + + [ValidateSet('Last24Hours', 'Last7Days', 'Last30Days', 'PreviousMonth', 'Month', 'Day', 'Custom')] + [string]$Range, + [string]$Month, + [string]$Date, + [string]$Start, + [string]$End, + + [string]$ConfigPath = (Join-Path $PSScriptRoot 'config\PurviewDlpReport.config.psd1'), + [switch]$IncludeRecipientDetails, + [ValidateSet('Rows', 'Day', 'Week')] + [string]$SplitBy, + [ValidateRange(1000, 1048575)] + [int]$MaxRowsPerFile, + [string]$OutputPath, + [switch]$NoCollect +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +# Numbers and dates are displayed the same way on every server (1,234.5), whatever the regional settings. +$previousCulture = [Threading.Thread]::CurrentThread.CurrentCulture +[Threading.Thread]::CurrentThread.CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo('en-US') +$clock = [Diagnostics.Stopwatch]::StartNew() +$exitCode = 1 +$runStatus = 'Failed' +$store = $null; $connection = $null; $lock = $null; $runId = $null; $details = [ordered]@{} + +try { + # do { } while ($false): 'break' ends the execution early; the finally block always runs. + do { + $integrityVerifier = Join-Path $PSScriptRoot 'Test-PackageIntegrity.ps1' + $integrityCursor = [IO.Path]::GetFullPath($integrityVerifier) + while ($integrityCursor) { + $integrityItem = Microsoft.PowerShell.Management\Get-Item -LiteralPath $integrityCursor -Force -ErrorAction Stop + if ($integrityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Package integrity: reparse point: $integrityCursor" + } + $integrityCursor = [IO.Path]::GetDirectoryName($integrityCursor) + } + $integritySignature = Microsoft.PowerShell.Security\Get-AuthenticodeSignature -LiteralPath $integrityVerifier -ErrorAction Stop + if ([string]$integritySignature.Status -cne 'Valid' -or $null -eq $integritySignature.SignerCertificate -or + $integritySignature.SignerCertificate.Thumbprint -cne '0F28E442889F17B5F4D251E8F96E7CC6E2C9C6D6' -or + $null -eq $integritySignature.TimeStamperCertificate) { + throw 'Package integrity: verifier must have the trusted, timestamped owner signature.' + } + & $integrityVerifier -Root $PSScriptRoot + Import-Module (Join-Path $PSScriptRoot 'PurviewDlpReport.psd1') -Force + + # --------------------------------------------------------------------------------- + # Configuration file, then the command-line overrides. + # --------------------------------------------------------------------------------- + $settings = Import-DlpConfiguration -Path $ConfigPath -Root $PSScriptRoot + if ($PSBoundParameters.ContainsKey('IncludeRecipientDetails')) { $settings.Report.IncludeRecipientDetails = [bool]$IncludeRecipientDetails } + if ($SplitBy) { $settings.Report.SplitBy = $SplitBy } + if ($MaxRowsPerFile) { $settings.Report.MaxRowsPerFile = $MaxRowsPerFile } + if ($OutputPath) { $settings.Report.OutputPath = [IO.Path]::GetFullPath($OutputPath, (Get-Location).Path) } + if ($Mode -eq 'Report' -and -not $Range) { $Range = $settings.Report.DefaultRange } + $zone = $settings.Zone + + $logPath = Start-DlpLog -Directory $settings.Logging.Path -RetentionDays $settings.Logging.RetentionDays + Initialize-DlpEngine -Root $PSScriptRoot + + $collecting = $Mode -eq 'Collect' -or ($Mode -eq 'Report' -and -not $NoCollect) + $totalSteps = switch ($Mode) { 'Status' { 2 } 'Collect' { 4 } default { 5 } } + $period = $null + if ($Mode -eq 'Report') { + $period = Resolve-DlpPeriod -Range $Range -Month $Month -Date $Date -Start $Start -End $End -Zone $zone + } + $dot = [char]0x00B7 + $modeText = $Mode + $(if ($period) { " $dot $Range" } elseif ($Mode -eq 'Collect') { " $dot last $($settings.Collection.BackfillDays) days" } else { '' }) + $banner = [ordered]@{ Mode = @('Info', $modeText) } + if ($period) { $banner['Period'] = @('Calendar', "$(Format-DlpRange $period.StartMs $period.EndMs $zone) ($($settings.Report.TimeZone), end excluded)") } + $ruleText = if ($settings.Target.RuleName) { $settings.Target.RuleName + $(if ($settings.Target.PolicyName) { " ($($settings.Target.PolicyName))" }) } else { "rule $($settings.Target.RuleId)" } + $banner['Rule'] = @('Target', $ruleText) + $banner['Database'] = @('Database', $settings.Storage.DatabasePath) + $banner['Log'] = @('Log', $logPath) + Write-DlpBanner -Title 'Purview DLP Report' -Subtitle "Exchange Online DLP matches $dot one row per Message ID" -Details $banner + + # --------------------------------------------------------------------------------- + # Step 1 - Database (and lock when this execution collects). + # --------------------------------------------------------------------------------- + Write-DlpStep 1 $totalSteps 'Opening the database' -Icon Database + if ($Mode -eq 'Status' -and -not (Test-Path -LiteralPath $settings.Storage.DatabasePath)) { + # New installation: nothing collected yet. The configuration is valid and the engine is built. + Write-DlpItem Info 'No database yet: the first collection (-Mode Collect) or the first report creates it.' + Write-DlpSummary -Title 'Ready for the first collection' -Values ([ordered]@{ + Config = @('Ok', 'valid') + Engine = @('Ok', 'ready') + Database = @('Database', 'none yet') + Next = @('Info', '.\Invoke-PurviewDlpReport.ps1 -Mode Collect') + }) -Status Ok + $exitCode = 0 + $runStatus = 'Completed' + break + } + if ($Mode -eq 'Status') { + $store = Open-DlpStore -Settings $settings -ReadOnly + } else { + if ($collecting) { $lock = Enter-DlpLock -Path ($settings.Storage.DatabasePath + '.lock') } + $store = Open-DlpStore -Settings $settings + if ($collecting) { + $closed = $store.CloseAbandonedWork() + if ($closed) { Write-DlpItem Warn "$closed collection window(s) left unfinished by an interrupted execution will be collected again." } + } + $runId = $store.StartRun($Mode, $null, [Environment]::MachineName, (Get-Module PurviewDlpReport).Version.ToString(), $null) + } + $targetId = if ($Mode -eq 'Status') { $store.FindTarget($settings.Target.PolicyId, $settings.Target.RuleId) } + else { $store.GetOrCreateTarget($settings.Target.PolicyId, $settings.Target.RuleId, $settings.Target.PolicyName, $settings.Target.RuleName) } + $stats = $store.GetStatistics($targetId) + Write-DlpItem Ok ("{0} events {3} {1} messages {3} {2}" -f (Format-DlpNumber $stats.Events), (Format-DlpNumber $stats.Messages), (Format-DlpBytes $stats.FileBytes), [char]0x00B7) + + # --------------------------------------------------------------------------------- + # Status mode: day-by-day view, then stop. + # --------------------------------------------------------------------------------- + if ($Mode -eq 'Status') { + Write-DlpStep 2 $totalSteps 'Collected data, day by day' -Icon Calendar + Show-DlpStatus -Store $store -TargetId $targetId -Settings $settings + $expiring = @(if ($targetId) { Get-DlpExpiringGaps -Store $store -TargetId $targetId -Settings $settings }) + foreach ($gap in $expiring) { Write-DlpItem Warn ("Missing {0}: Activity Explorer keeps it for about {1} more day(s). Run -Mode Collect." -f (Format-DlpRange $gap.Start $gap.End $zone), $gap.DaysLeft) } + $exitCode = 0 + $runStatus = 'Completed' + break + } + + # --------------------------------------------------------------------------------- + # Step 2 - What must be collected? + # --------------------------------------------------------------------------------- + $nowMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + if ($Mode -eq 'Collect') { + Write-DlpStep 2 $totalSteps 'Planning the collection' -Icon Plan + $windowStart = [Math]::Max($nowMs - [long]$settings.Collection.BackfillDays * 86400000, $nowMs - [long]$settings.Collection.SourceRetentionDays * 86400000 + 3600000) + $plan = Get-DlpCollectionPlan -Store $store -TargetId $targetId -StartMs $windowStart -EndMs $nowMs -Settings $settings -NowMs $nowMs + } else { + Write-DlpStep 2 $totalSteps 'Checking the data available for the period' -Icon Plan + $plan = Get-DlpCollectionPlan -Store $store -TargetId $targetId -StartMs $period.StartMs -EndMs $period.EndMs -Settings $settings -NowMs $nowMs + } + $slices = $plan.Slices + if ($slices.Count) { + $refresh = if ($plan.RefreshMs -gt 0) { " (of which {0} already collected, refreshed to catch late events)" -f (Format-DlpDuration ($plan.RefreshMs / 1000)) } else { '' } + Write-DlpItem Info ("To collect: {0} window(s), {1} in total{2}" -f $slices.Count, (Format-DlpDuration ($plan.CollectableMs / 1000)), $refresh) + } else { + Write-DlpItem Ok 'Everything is already in the database.' + } + foreach ($gap in $plan.Unrecoverable) { + Write-DlpItem Warn ("Not in the database and older than the {0} days kept by Activity Explorer: {1}" -f $settings.Collection.SourceRetentionDays, (Format-DlpRange $gap.Start $gap.End $zone)) + } + if ($settings.Collection.SettlingHours -eq 0) { + Write-DlpItem Warn 'SettlingHours is 0: late Activity Explorer events will not be recovered by a later collection.' + } + if ($Mode -eq 'Report' -and $NoCollect -and $plan.NewMs -gt 0) { + Write-DlpItem Warn '-NoCollect: the missing part will not be collected; the report will be incomplete.' + } elseif ($Mode -eq 'Report' -and $NoCollect -and $plan.RefreshMs -gt 0) { + Write-DlpItem Info '-NoCollect: recent data is not refreshed; events that reached Activity Explorer late may be missing.' + } + + # --------------------------------------------------------------------------------- + # Steps 3 and 4 - Connection and collection (only when something is missing). + # --------------------------------------------------------------------------------- + $collection = $null + if ($collecting -and $slices.Count) { + Write-DlpStep 3 $totalSteps 'Connecting to Security & Compliance PowerShell' -Icon Key + if ($settings.Authentication.Mode -eq 'Interactive') { Write-DlpItem Info 'A sign-in window may open: sign in with the administrator account.' -Icon People } + $connectClock = [Diagnostics.Stopwatch]::StartNew() + $connection = Connect-DlpActivityExplorer -Settings $settings + $legacyBinding = $store.BindAuthenticatedTenant($connection.TenantId) + if ($legacyBinding) { Write-DlpItem Warn 'This legacy database had no tenant binding. Future collections are now restricted to the verified tenant; the tenant of historical rows cannot be independently verified. Use a separate database for each tenant.' } + $store.UpdateRunAccount($runId, $connection.Account) + Write-DlpItem Ok ("{0} {3} {1} mode {3} tenant verified {3} {2}" -f $connection.Account, $connection.Mode.ToLowerInvariant(), (Format-DlpDuration $connectClock.Elapsed.TotalSeconds), [char]0x00B7) + + Write-DlpStep 4 $totalSteps 'Collecting Activity Explorer data' -Icon Download + $collection = Invoke-DlpCollection -Store $store -RunId $runId -TargetId $targetId -Settings $settings -Slices $slices -Exporter $connection.Exporter + $details['Collection'] = $collection + if ($collection.Quarantined) { Write-DlpItem Warn ("{0} record(s) could not be read and were set aside (table 'quarantine'): {1}" -f $collection.Quarantined, (($collection.QuarantineReasons.GetEnumerator() | ForEach-Object { "$($_.Key) x$($_.Value)" }) -join ', ')) } + if ($collection.Conflicts) { Write-DlpItem Info ("{0} event(s) had a sender, subject or recipient list different from an earlier event of the same message; the first values are kept." -f $collection.Conflicts) } + Write-DlpCollectionDiagnostics -Collection $collection -Zone $zone + if ($collection.Completed) { + Write-DlpItem Ok ("Collection complete: {0} events received, {1} new, in {2} (Activity Explorer: {3})" -f (Format-DlpNumber $collection.Records), (Format-DlpNumber $collection.NewEvents), (Format-DlpDuration $collection.Seconds), (Format-DlpDuration $collection.SourceSeconds)) + } else { + Write-DlpItem Fail ("Collection stopped: {0}" -f $collection.Error) + Write-DlpItem Info 'What was received is kept. Run the same command again to continue from the missing windows.' + } + } elseif ($collecting) { + Write-DlpStep 3 $totalSteps 'Connecting to Security & Compliance PowerShell' -Icon Key + Write-DlpItem Skip 'Not needed.' + Write-DlpStep 4 $totalSteps 'Collecting Activity Explorer data' -Icon Download + Write-DlpItem Skip 'Nothing to collect.' + } else { + Write-DlpStep 3 $totalSteps 'Connecting to Security & Compliance PowerShell' -Icon Key + Write-DlpItem Skip '-NoCollect: no connection.' + Write-DlpStep 4 $totalSteps 'Collecting Activity Explorer data' -Icon Download + Write-DlpItem Skip '-NoCollect: the report uses the data already collected.' + } + if ($collecting) { + $purge = Invoke-DlpRetention -Store $store -Settings $settings + if ($purge -and $purge.Events) { Write-DlpItem Info ("Retention ({0} days): {1} old events deleted." -f $settings.Storage.RetentionDays, (Format-DlpNumber $purge.Events)) } + } + $collectionFailed = $collection -and -not $collection.Completed + + # --------------------------------------------------------------------------------- + # Collect mode ends here. + # --------------------------------------------------------------------------------- + if ($Mode -eq 'Collect') { + $expiring = @(Get-DlpExpiringGaps -Store $store -TargetId $targetId -Settings $settings) + foreach ($gap in $expiring) { Write-DlpItem Warn ("Still missing {0}: collectable for about {1} more day(s)." -f (Format-DlpRange $gap.Start $gap.End $zone), $gap.DaysLeft) } + $stats = $store.GetStatistics($targetId) + $summary = [ordered]@{ + 'Result' = @($(if ($collectionFailed) { 'Fail' } else { 'Ok' }), $(if ($collectionFailed) { 'Incomplete - run again to continue' } else { 'Collection complete' })) + 'Events' = @('Download', $(if ($collection) { '{0} received, {1} new' -f (Format-DlpNumber $collection.Records), (Format-DlpNumber $collection.NewEvents) } else { 'nothing to collect' })) + 'Database' = @('Database', ('{0} events {3} {1} messages {3} {2}' -f (Format-DlpNumber $stats.Events), (Format-DlpNumber $stats.Messages), (Format-DlpBytes $stats.FileBytes), [char]0x00B7)) + 'Duration' = @('Clock', (Format-DlpDuration $clock.Elapsed.TotalSeconds)) + 'Log' = @('Log', $logPath) + } + $status = if ($collectionFailed) { 'Fail' } elseif ($expiring.Count) { 'Warn' } else { 'Ok' } + Write-DlpSummary -Title $(if ($collectionFailed) { 'Collection incomplete' } else { 'Collection finished' }) -Values $summary -Status $status + $exitCode = if ($collectionFailed) { 1 } else { 0 } + $runStatus = if ($collectionFailed) { 'Incomplete' } else { 'Completed' } + break + } + + # --------------------------------------------------------------------------------- + # Step 5 - Report files. + # --------------------------------------------------------------------------------- + Write-DlpStep 5 $totalSteps 'Writing the report' -Icon Report + $coverage = Get-DlpCoverage -Store $store -TargetId $targetId -StartMs $period.StartMs -EndMs $period.EndMs -SettlingMs ([long]$settings.Collection.SettlingHours * 3600000) + $reportClock = [Diagnostics.Stopwatch]::StartNew() + $report = New-DlpReport -Store $store -TargetId $targetId -Settings $settings -Period $period -Coverage $coverage -CollectionFailed:([bool]$collectionFailed) + foreach ($file in $report.Result.Files) { + Write-DlpItem Ok ("{0,-4} {1} {2} messages {4} {3}" -f $file.Kind, (Split-Path $file.Path -Leaf), (Format-DlpNumber $file.Rows), (Format-DlpBytes $file.Bytes), [char]0x00B7) -Icon File + } + Write-DlpItem Info ("Written in {0}" -f (Format-DlpDuration $reportClock.Elapsed.TotalSeconds)) -Icon Clock + $r = $report.Result + if ($r.MessagesWithoutCount) { Write-DlpItem Info ("{0} message(s) without a reliable recipient count (column left empty)." -f (Format-DlpNumber $r.MessagesWithoutCount)) } + $complete = $coverage.MissingMs -eq 0 -and -not $collectionFailed + $summary = [ordered]@{ + 'Period' = @('Calendar', "$(Format-DlpRange $period.StartMs $period.EndMs $zone) ($($settings.Report.TimeZone))") + 'Messages' = @('Mail', ('{0} unique messages {2} {1} senders' -f (Format-DlpNumber $r.Messages), (Format-DlpNumber $r.Senders), [char]0x00B7)) + 'Coverage' = @($(if ($coverage.MissingMs -eq 0) { 'Chart' } else { 'Warn' }), $(if ($coverage.MissingMs -eq 0) { '{0}% of queried time{1}' -f $coverage.Percent, $(if ($coverage.ProvisionalMs -gt 0) { "; settled: $($coverage.FinalPercent)% (recent data is provisional)" } else { '' }) } else { '{0}% of queried time (rounded) - missing: {1} ({2} ms)' -f $coverage.Percent, ((@($coverage.Gaps) | Select-Object -First 3 | ForEach-Object { Format-DlpRange $_.Start $_.End $zone }) -join ', '), $coverage.MissingMs })) + 'Files' = @('File', ('{0} file(s) {2} recipients {1}' -f $r.Files.Count, $(if ($settings.Report.IncludeRecipientDetails) { 'listed' } else { 'counted only' }), [char]0x00B7)) + 'Folder' = @('Folder', $report.Directory) + 'Duration' = @('Clock', (Format-DlpDuration $clock.Elapsed.TotalSeconds)) + 'Log' = @('Log', $logPath) + } + $summary['Metadata'] = @('File', 'report-metadata.json (coverage status; CSV columns unchanged)') + Write-DlpSummary -Title $(if ($complete) { 'Report ready' } else { 'Report written, but INCOMPLETE' }) -Values $summary -Status $(if ($complete) { 'Ok' } else { 'Warn' }) + $details['Report'] = [ordered]@{ Directory = $report.Directory; Messages = $r.Messages; Files = @($r.Files | ForEach-Object { $_.Path }); CoverageComplete = $complete; MissingMs = $coverage.MissingMs; ProvisionalMs = $coverage.ProvisionalMs; CoveragePercent = $coverage.Percent; SettledCoveragePercent = $coverage.FinalPercent } + $exitCode = if ($complete) { 0 } else { 2 } + $runStatus = if ($complete) { 'Completed' } else { 'Incomplete' } + } while ($false) +} +catch { + $message = $_.Exception.Message + Write-Host '' + if (Get-Command Write-DlpSummary -ErrorAction SilentlyContinue) { + Write-DlpSummary -Title 'Execution stopped' -Values ([ordered]@{ Error = @('Fail', $message); Duration = @('Clock', (Format-DlpDuration $clock.Elapsed.TotalSeconds)) }) -Status Fail + } else { + Write-Host " [ERROR] $message" + } + if (Get-Command Write-DlpLog -ErrorAction SilentlyContinue) { Write-DlpLog -Level 'ERROR' -Message ($message + "`n" + $_.ScriptStackTrace) } + $details['Error'] = $message + $exitCode = 1 +} +finally { + if ($store -and $runId) { + try { $store.FinishRun($runId, $runStatus, ($details | ConvertTo-Json -Depth 6 -Compress)) } catch { } + } + if ($store) { $store.Dispose() } + if (Get-Command Disconnect-DlpActivityExplorer -ErrorAction SilentlyContinue) { Disconnect-DlpActivityExplorer -Connection $connection } + if (Get-Command Exit-DlpLock -ErrorAction SilentlyContinue) { Exit-DlpLock -Lock $lock } + if (Get-Command Write-DlpLog -ErrorAction SilentlyContinue) { + Write-DlpLog -Level 'INFO' -Message ("Exit code {0} after {1:0.0} s" -f $exitCode, $clock.Elapsed.TotalSeconds) + Stop-DlpLog + } + [Threading.Thread]::CurrentThread.CurrentCulture = $previousCulture +} +exit $exitCode + +# SIG # Begin signature block +# MIIghQYJKoZIhvcNAQcCoIIgdjCCIHICAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBVh/RF+CK94BpR +# eafSl8mNxEG5qnIwQS+Hr5wNevSshaCCDW8wggZyMIIEWqADAgECAghkM1HTxzif +# CDANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +# EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8G +# A1UEAwwoU1NMLmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTAe +# Fw0xNjA2MjQyMDQ0MzBaFw0zMTA2MjQyMDQ0MzBaMHgxCzAJBgNVBAYTAlVTMQ4w +# DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjERMA8GA1UECgwIU1NMIENv +# cnAxNDAyBgNVBAMMK1NTTC5jb20gQ29kZSBTaWduaW5nIEludGVybWVkaWF0ZSBD +# QSBSU0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCfgxNzqrDG +# bSHL24t6h3TQcdyOl3Ka5LuINLTdgAPGL0WkdJq/Hg9Q6p5tePOf+lEmqT2d0bKU +# Vz77OYkbkStW72fL5gvjDjmMxjX0jD3dJekBrBdCfVgWQNz51ShEHZVkMGE6ZPKX +# 13NMfXsjAm3zdetVPW+qLcSvvnSsXf5qtvzqXHnpD0OctVIFD+8+sbGP0EmtpuNC +# GVQ/8y8Ooct8/hP5IznaJRy4PgBKOm8yMDdkHseudQfYVdIYyQ6KvKNc8HwKp4WB +# wg6vj5lc02AlvINaaRwlE81y9eucgJvcLGfE3ckJmNVz68Qho+Uyjj4vUpjGYDdk +# jLJvSlRyGMwnh/rNdaJjIUy1PWT9K6abVa8mTGC0uVz+q0O9rdATZlAfC9KJpv/X +# gAbxwxECMzNhF/dWH44vO2jnFfF3VkopngPawismYTJboFblSSmNNqf1x1KiVgMg +# Lzh4gL32Bq5BNMuURb2bx4kYHwu6/6muakCZE93vUN8BuvIE1tAx3zQ4XldbyDge +# VtSsSKbt//m4wTvtwiS+RGCnd83VPZhZtEPqqmB9zcLlL/Hr9dQg1Zc0bl0EawUR +# 0tOSjAknRO1PNTFGfnQZBWLsiePqI3CY5NEv1IoTGEaTZeVYc9NMPSd6Ij/D+KNV +# t/nmh4LsRR7Fbjp8sU65q2j3m2PVkUG8qQIDAQABo4H7MIH4MA8GA1UdEwEB/wQF +# MAMBAf8wHwYDVR0jBBgwFoAU3QQJB6L1en1SUxKSle44gCUNplkwMAYIKwYBBQUH +# AQEEJDAiMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcHMuc3NsLmNvbTARBgNVHSAE +# CjAIMAYGBFUdIAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwOwYDVR0fBDQwMjAwoC6g +# LIYqaHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0G +# A1UdDgQWBBRUwv4QlQCTzWr158DX2bJLuI8M4zAOBgNVHQ8BAf8EBAMCAYYwDQYJ +# KoZIhvcNAQELBQADggIBAPUPJodwr5miyvXWyfCNZj05gtOII9iCv49UhCe204MH +# 154niU2EjlTRIO5gQ9tXQjzHsJX2vszqoz2OTwbGK1mGf+tzG8rlQCbgPW/M9r1x +# xs19DiBAOdYF0q+UCL9/wlG3K7V7gyHwY9rlnOFpLnUdTsthHvWlM98CnRXZ7WmT +# V7pGRS6AvGW+5xI+3kf/kJwQrfZWsqTU+tb8LryXIbN2g9KR+gZQ0bGAKID+260P +# Z+34fdzZcFt6umi1s0pmF4/n8OdX3Wn+vF7h1YyfE7uVmhX7eSuF1W0+Z0duGwdc +# +1RFDxYRLhHDsLy1bhwzV5Qe/kI0Ro4xUE7bM1eV+jjk5hLbq1guRbfZIsr0WkdJ +# LCjoT4xCPGRo6eZDrBmRqccTgl/8cQo3t51Qezxd96JSgjXktefTCm9r/o35pNfV +# HUvnfWII+NnXrJlJ27WEQRQu9i5gl1NLmv7xiHp0up516eDap8nMLDt7TAp4z5T3 +# NmC2gzyKVMtODWgqlBF1JhTqIDfM63kXdlV4cW3iSTgzN9vkbFnHI2LmvM4uVEv9 +# XgMqyN0eS3FE0HU+MWJliymm7STheh2ENH+kF3y0rH0/NVjLw78a3Z9UVm1F5VPz +# iIorMaPKPlDRADTsJwjDZ8Zc6Gi/zy4WZbg8Zv87spWrmo2dzJTw7XhQf+xkR6Od +# MIIG9TCCBN2gAwIBAgIQBof4ljXihJpmCqqt2mhAWzANBgkqhkiG9w0BAQsFADB4 +# MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24x +# ETAPBgNVBAoMCFNTTCBDb3JwMTQwMgYDVQQDDCtTU0wuY29tIENvZGUgU2lnbmlu +# ZyBJbnRlcm1lZGlhdGUgQ0EgUlNBIFIxMB4XDTI2MTAxMzAyNDgwMloXDTI3MTAx +# MzAyNDgwMlowXDELMAkGA1UEBhMCRlIxGzAZBgNVBAcMEkNoYW50ZWxvdXAtRW4t +# QnJpZTEXMBUGA1UECgwOTmljb2xhcyBGYWJlcnQxFzAVBgNVBAMMDk5pY29sYXMg +# RmFiZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsg6CT0mFQ4jB +# V2plIS4uWFSoVqRHd82Y7XYZ4CUTe/7nPBRUlXXdKTZMMHzGgiG8dEXNE8K1Xe90 +# Dgl/FnorzAFlrNWSwf6t27UAUw9xkqtRDVVpVgpixpWkAwjcQeLG+YMqBkGbFi35 +# TGvS23lPFWvLp4DxARrv15MtZ/NjNTCAAGUlMP2hFVBknEmrnYjEGyeJ93sl20RS +# WXQQLNqlenBdkGCrQW4XjHtRjJAEqAC68hN6nWevopSQ8CLUV7A1RFfHFarDrzrL +# zr0/NjIdSyIfM9hShln31NDrktB6Ups2XIyrtPDNB7fjkW4jpLlOqQGfYKsU9ErE +# 4SnFsh0yOOjitegyh1RvZ9wH++3DF4CmJfTli5l/gEIm8xCCryxI+K5EogW3wu1y +# u8NT2hcvHjdh7OpRyPyFAuLZ8MfX7kPAJpnuCeOuktzY/20eVD8bDglj+4zgqzsn +# cR/t6T/7w1/HxpRyrVoKvEyRx+mphYh5jMdEa0BjxOL0vAJpdGZSfXGmPm/yPqRn +# 5STTkMtzkVQXPbcvgKM0luzFcQKPHk3qdHLEXa8gf93ktYDbLE5Wd2UHrXz3SZxN +# URHPdIXCr1qgrqQve7041kMpzrkDI5DnG2lvRG4108gp02bHG0muKyZlDdCOra7h +# Zonu2nO79sFallruzzNi0o994PoWksUCAwEAAaOCAZUwggGRMAwGA1UdEwEB/wQC +# MAAwHwYDVR0jBBgwFoAUVML+EJUAk81q9efA19myS7iPDOMwegYIKwYBBQUHAQEE +# bjBsMEgGCCsGAQUFBzAChjxodHRwOi8vY2VydC5zc2wuY29tL1NTTGNvbS1TdWJD +# QS1Db2RlU2lnbmluZy1SU0EtNDA5Ni1SMS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6 +# Ly9vY3Nwcy5zc2wuY29tMFEGA1UdIARKMEgwCAYGZ4EMAQQBMDwGDCsGAQQBgqkw +# AQMDATAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3JlcG9zaXRv +# cnkwEwYDVR0lBAwwCgYIKwYBBQUHAwMwTQYDVR0fBEYwRDBCoECgPoY8aHR0cDov +# L2NybHMuc3NsLmNvbS9TU0xjb20tU3ViQ0EtQ29kZVNpZ25pbmctUlNBLTQwOTYt +# UjEuY3JsMB0GA1UdDgQWBBRoUx3630l9wA1fUpkKkw2GUIxl/jAOBgNVHQ8BAf8E +# BAMCB4AwDQYJKoZIhvcNAQELBQADggIBAE3oj6cNuwlDToi1BwjgsA5djfRN4DIi +# BYDFZIRYaIRe/rEdnItz3aqQQdFuwXnJA/MQEmi2WAGek8GeZF+R41s8FbcDxBUE +# BoSt98TuBE+0xgxQWDiPhhXaX1Aw9V0GF4ZnlfCSw1eu2KiASaKn7Bo0PLbEAXff +# vftRXof/kyqtw8iID6I4Rhrf5DI3WVZQodakUUmWnbnziWvEZOWbNOfzwP+7BwEt +# 7jQANfbmEfb4p75rfy5dQVwniTDSvdo6xkrbe1Cr93GS7cJta18IG5CoIVUmdK+s +# W38B1sLuy8Mi0MstMXV3004yaRuzLdNaAM0f9WAFerxemiQC0kdmA44/lWV3lVkM +# WJSwkHBkkSg8Eh0hxcGzTH2xmtDhLJHNNLRwkI9u2Ur+HbLCt50oBgSV8BcCs/1i +# qi884iC/yuX+HIv9YgTUNZ8c4lLaq4VuliJpXLu2buHIsTkO0v0+5oCM91zjTVZm +# ULeH38N0Vr83psiJVuBSxQyt5nRVrpYz7FACq5gtXELkyViq/rGOlJKaRrgQ6WWq +# eoIXqKhVFAdP/jAEm2M8ezoVYsUgPyKY2OcuecV9coulAcxUIUydx6qYiOfsGj5u +# Dy7l4aYjvB771wkUMzUQwxzcDYXJtMMTaWRe6n9EemR67jc73u5CJyFg3PfA5WR1 +# wZislcdCZuS6MYISbDCCEmgCAQEwgYwweDELMAkGA1UEBhMCVVMxDjAMBgNVBAgM +# BVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDE0MDIG +# A1UEAwwrU1NMLmNvbSBDb2RlIFNpZ25pbmcgSW50ZXJtZWRpYXRlIENBIFJTQSBS +# MQIQBof4ljXihJpmCqqt2mhAWzANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3 +# AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisG +# AQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCA9CDNAUYr+ +# ibqx6AMpbnlv1H27TkCmtakiLLLVSEEKqzANBgkqhkiG9w0BAQEFAASCAgAYfLcd +# MqtSJSx1Id317qKOs68MQFJ6ycfWgVJ+WhOiilB587QyHzMLyPt/l3q3VPKZrLw+ +# mvE3OIm5NXPAEFW50Qs0gUd4O4S20fH1RKfJkcAe+9HpHzR7yq74a8uPP1IhAdxI +# SKHmY3rHFXJshjx6N2eJCog1dT2CQuZP2iwa2jz0dHAaDa6VHvAq+Bp3TOz+ns3D +# 3206C9YlBk1bZAoKQjWW1we0s7Gfl+UAIgDU+4Y3+izNfN5RNlzwcOmtSFi/45QM +# om61tDuEpGsUykjk+LT/j/Bpw5E9jKPIQ2MudNaT0GsJIcgP94FIY+eC9B+4+BXm +# Kfq4YNEBGite5wg2p1+YHjIyfDZZGrtHPpCvZKnZEjvOYGRVfSGuAEW199M2Mmgb +# hYw6vb6cAFqBmbUEOp6pC9Mv1EiAwxnTT3HVZOKOztkokxtbYnkP1I/h0D5nDAS1 +# fB7WvftAY/uC6aSlQ8t0AHQkJDmnklblp6fQkdAqedRpgDM7ETh+rQlJsoGNKPuB +# +SXNdd2jsEKMCMqi9j2CycEo64oA8KHhs434sdZM2d9Snelhc8Vf3VFgfwL+7Ey7 +# 4i7mcK8Ie2gc7Yc3Fzo+Kh4s6tYEEJI49HVdS7q5mGBBIo0dscILJW4yvMRoIKR0 +# pxjK16IdmaS8WKUwatVv1TcnNGdcA0XD0AkVs6GCDykwgg8lBgorBgEEAYI3AwMB +# MYIPFTCCDxEGCSqGSIb3DQEHAqCCDwIwgg7+AgEDMQ0wCwYJYIZIAWUDBAIBMIGJ +# BgsqhkiG9w0BCRABBKB6BHgwdgIBAQYMKwYBBAGCqTABAwYBMDEwDQYJYIZIAWUD +# BAIBBQAEIC9Gf/hqBxOGne5Rim6Ih2p+QEtdjrpn2TCLFpBnBnYyAghZUZguYL5z +# lhgPMjAyNjEwMTMxMTQyNDhaMAMCAQECEAVJA+lOup+1ZvTmnR+KEhygggwAMIIE +# /DCCAuSgAwIBAgIQH2sWYtIuG2xd8cDBoGAOODANBgkqhkiG9w0BAQsFADBzMQsw +# CQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24xETAP +# BgNVBAoMCFNTTCBDb3JwMS8wLQYDVQQDDCZTU0wuY29tIFRpbWVzdGFtcGluZyBJ +# c3N1aW5nIFJTQSBDQSBSMTAeFw0yNTAyMTgxNjMyMDJaFw0zNDExMTIxODUwMDVa +# MG4xCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3Rv +# bjERMA8GA1UECgwIU1NMIENvcnAxKjAoBgNVBAMMIVNTTC5jb20gVGltZXN0YW1w +# aW5nIFVuaXQgMjAyNSBFMTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBv7UVHH +# b+ZVluxPXlfE3M6tg0Xnq8dic+O3vPOCRpalUM1vO9A+GRzSVVjyygHhYrBw62XL +# Fh1kv7e+yRd/aTajggFaMIIBVjAfBgNVHSMEGDAWgBQMnRAljpqnG5mHQ88IfuG9 +# gZD0zzBRBggrBgEFBQcBAQRFMEMwQQYIKwYBBQUHMAKGNWh0dHA6Ly9jZXJ0LnNz +# bC5jb20vU1NMLmNvbS10aW1lU3RhbXBpbmctSS1SU0EtUjEuY2VyMFEGA1UdIARK +# MEgwPAYMKwYBBAGCqTABAwYBMCwwKgYIKwYBBQUHAgEWHmh0dHBzOi8vd3d3LnNz +# bC5jb20vcmVwb3NpdG9yeTAIBgZngQwBBAIwFgYDVR0lAQH/BAwwCgYIKwYBBQUH +# AwgwRgYDVR0fBD8wPTA7oDmgN4Y1aHR0cDovL2NybHMuc3NsLmNvbS9TU0wuY29t +# LXRpbWVTdGFtcGluZy1JLVJTQS1SMS5jcmwwHQYDVR0OBBYEFM582cAEgMUkEGoJ +# 6hyrJT0R/ajSMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAgHN1 +# JIW9ZlNip07FfY9r1LJwLKTl1h/y9r1BjDgHMEz7U7J7mAy32JxA1KLyjCCJqNku +# RfhDHGFEfZtjTOru7AQMDSDiUDvCCc3jaz5py9jGvwIe5npkLbXqXhA+SqCzG719 +# USigwhHkYr7v8NiXoaY0Xu03p0BiZZreZlpngmGB9+86N94FqIFx2I5Z5iuu7d4j +# 7aAOZBNxfU9jFDIIGDtWfWeCpF0Q8rLE/3DHDlzJKs5Il3i/VoOl7rTU938oSrxM +# www6GDBlKX8rGHxvkjgIb9MXkVglJSDHsNAZDeCS/inaxKHA+ChuB8K4OxIxa6k1 +# e+eHBypakAY3wuN7w7PlPmhN6k5IdqZn3HZHs2VjoR841Z9wmVPFdGDdkBZ7XOmb +# 5OZUaKufsvtER0VbY2DkzaqAaItt1kc+I+FUz7PiVz2PUzpegkftRRxvrPyIL08b +# lJTnbMQ0XrqDN0rEfuTLv4XhhnKyJaXhxCUdjO/cRumMzZ35QTUMItBWy1xGp0iy +# oFVnAya2pPsVMuAI+sy1zxlOS9l5iSxvKJ8gpnpOgjqYTa7u1eyZo+4JlcGoiiR1 +# 7LthMTrF1q62tO5xOpI8txUZ6gtKGtkJV6wdj+vZLcwKZ2M93GqVpOo6UBlGjx1s +# KsVZ7AIUzJZli4NJ0M2KYcrfNnlebkYKpDnSqWwwggb8MIIE5KADAgECAhBtUhhw +# h+gjTYVgANCAj5NWMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMQ4wDAYD +# VQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBv +# cmF0aW9uMTEwLwYDVQQDDChTU0wuY29tIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRo +# b3JpdHkgUlNBMB4XDTE5MTExMzE4NTAwNVoXDTM0MTExMjE4NTAwNVowczELMAkG +# A1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYD +# VQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNvbSBUaW1lc3RhbXBpbmcgSXNz +# dWluZyBSU0EgQ0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCu +# URAT0vk8IKAghd7JUBxkyeH9xek0/wp/MUjoclrFXqhh/fGH91Fc+7fm0MHCE7A+ +# wmOiqBj9ODrJAYGq3rm33jCnHSsCBNWAQYyoauLq8IjqsS1JlXL29qDNMMdwZ8UN +# zQS7vWZMDJ40JSGNphMGTIA2qn2bohGtgRc4p1395ESypUOaGvJ3t0FNL3BuKmb6 +# YctMcQUF2sqooMzd89h0E6ujdvBDo6ZwNnWoxj7YmfWjSXg33A5GuY9ym4QZM5OE +# Vgo8ebz/B+gyhyCLNNhh4Mb/4xvCTCMVmNYrBviGgdPZYrym8Zb84TQCmSuX0JlL +# La6WK1aO6qlwISbb9bVGh866ekKblC/XRP20gAu1CjvcYciUgNTrGFg8f8AJgQPO +# Cc1/CCdaJSYwhJpSdheKOnQgESgNmYZPhFOC6IKaMAUXk5U1tjTcFCgFvvArXtK4 +# azAWUOO1Y3fdldIBL6LjkzLUCYJNkFXqhsBVcPMuB0nUDWvLJfPimstjJ8lF4S6E +# CxWnlWi7OElVwTnt1GtRqeY9ydvvGLntU+FecK7DbqHDUd366UreMkSBtzevAc9a +# qoZPnjVMjvFqV1pYOjzmTiVHZtAc80bAfFe5LLfJzPI6DntNyqobpwTevQpHqPDN +# 9qqNO83r3kaw8A9j+HZiSw2AX5cGdQP0kG0vhzfgBwIDAQABo4IBgTCCAX0wEgYD +# VR0TAQH/BAgwBgEB/wIBADAfBgNVHSMEGDAWgBTdBAkHovV6fVJTEpKV7jiAJQ2m +# WTCBgwYIKwYBBQUHAQEEdzB1MFEGCCsGAQUFBzAChkVodHRwOi8vd3d3LnNzbC5j +# b20vcmVwb3NpdG9yeS9TU0xjb21Sb290Q2VydGlmaWNhdGlvbkF1dGhvcml0eVJT +# QS5jcnQwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3Nwcy5zc2wuY29tMD8GA1UdIAQ4 +# MDYwNAYEVR0gADAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3Jl +# cG9zaXRvcnkwEwYDVR0lBAwwCgYIKwYBBQUHAwgwOwYDVR0fBDQwMjAwoC6gLIYq +# aHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0GA1Ud +# DgQWBBQMnRAljpqnG5mHQ88IfuG9gZD0zzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +# hvcNAQELBQADggIBAJIZdQ2mWkLPGQfZ8vyU+sCb8BXpRJZaL3Ez3VDlE3uZk3cP +# xPtybVfLuqaci0W6SB22JTMttCiQMnIVOsXWnIuAbD/aFTcUkTLBI3xys+wEajzX +# aXJYWACDS47BRjDtYlDW14gLJxf8W6DQoH3jHDGGy8kGJFOlDKG7/YrK7UGfHtBA +# EDVe6lyZ+FtCsrk7dD/IiL/+Q3Q6SFASJLQ2XI89ihFugdYL77CiDNXrI2MFspQG +# swXEAGpHuaQDTHUp/LdR3TyrIsLlnzoLskUGswF/KF8+kpWUiKJNC4rPWtNrxlbX +# YRGgdEdx8SMjUTDClldcrknlFxbqHsVmr9xkT2QtFmG+dEq1v5fsIK0vHaHrWjMM +# maJ9i+4qGJSD0stYfQ6v0PddT7EpGxGd867Ada6FZyHwbuQSadMb0K0P0OC2r7rw +# qBUe0BaMqTa6LWzWItgBjGcObXeMxmbQqlEz2YtAcErkZvh0WABDDE4U8GyV/32F +# daAvJgTfe9MiL2nSBioYe/g5mHUSWAay/Ip1RQmQCvmF9sNfqlhJwkjy/1U1ibUk +# TIUBX3HgymyQvqQTZLLys6pL2tCdWcjI9YuLw30rgZm8+K387L7ycUvqrmQ3ZJlu +# jHl3r1hgV76s3WwMPgKk1bAEFMj+rRXimSC+Ev30hXZdqyMdl/il5Ksd0vhGMYIC +# WDCCAlQCAQEwgYcwczELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD +# VQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNv +# bSBUaW1lc3RhbXBpbmcgSXNzdWluZyBSU0EgQ0EgUjECEB9rFmLSLhtsXfHAwaBg +# DjgwCwYJYIZIAWUDBAIBoIIBYTAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQw +# HAYJKoZIhvcNAQkFMQ8XDTI2MTAxMzExNDI0OFowKAYJKoZIhvcNAQk0MRswGTAL +# BglghkgBZQMEAgGhCgYIKoZIzj0EAwIwLwYJKoZIhvcNAQkEMSIEIOY+Y0kpUMWm +# YBYYjWz5HU3UMXO2IQfTqo1Ax5zDNRGnMIHJBgsqhkiG9w0BCRACLzGBuTCBtjCB +# szCBsAQgVCr5oWqNci5mEUl4iumUwYqaruWmXLNEolSa+Wx5x4swgYswd6R1MHMx +# CzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjER +# MA8GA1UECgwIU1NMIENvcnAxLzAtBgNVBAMMJlNTTC5jb20gVGltZXN0YW1waW5n +# IElzc3VpbmcgUlNBIENBIFIxAhAfaxZi0i4bbF3xwMGgYA44MAoGCCqGSM49BAMC +# BEcwRQIgVkaW5eq+BWBo767YpkoPtjzhgbauPL0lVZMfUoZFSxICIQD7RMGbqo7h +# jtNuX9LBeV5RDMUyV+3U7oMuhjcec8XBnA== +# SIG # End signature block diff --git a/package/PackageIntegrity.psd1 b/package/PackageIntegrity.psd1 new file mode 100644 index 0000000..e9ce196 --- /dev/null +++ b/package/PackageIntegrity.psd1 @@ -0,0 +1,202 @@ +# Purview DLP Report 2.1.2 - authenticated package integrity manifest +# Generate only after final stable-file signature/timestamp bytes. +@{ + SchemaVersion = 1 + Algorithm = 'SHA256' + SignerThumbprint = '0F28E442889F17B5F4D251E8F96E7CC6E2C9C6D6' + Files = @( + @{ Path = 'Invoke-PurviewDlpReport.ps1'; Sha256 = 'D0EFD23D4492BE231E9D3BAB56ECD46D87611A9FDD0C3C7BFB76E24B65BCDA4F' } + @{ Path = 'PurviewDlpReport.psm1'; Sha256 = 'E4238B6B2FBB0BA9E9CF71749769DC8B945B804B59284D5804B07E6AAB56D57A' } + @{ Path = 'PurviewDlpReport.psd1'; Sha256 = '7107007D2483506EB7ED5FAE8A66392E2D894CEEF49C629FE4C7EFE4CCCDAB9E' } + @{ Path = 'Test-PackageIntegrity.ps1'; Sha256 = '0559BBEB5F699AFDDADE9C2A7CEF6503231C36C6D76185A91E51CE91D09BCC54' } + @{ Path = 'src\PurviewDlpReport.Engine.cs'; Sha256 = 'BDD38D1ED66034174103A3256424FDAAEDA310B24AAF994658CEC37B601ED6ED' } + @{ Path = 'lib\sqlite\Microsoft.Data.Sqlite.dll'; Sha256 = 'B98D957462BE432895B00DCCCA012D3C127E118D3D4262418014B92C7BBD66CF' } + @{ Path = 'lib\sqlite\SQLitePCLRaw.core.dll'; Sha256 = 'AF340AC9EB9195795660D7FC1114F486B6AB63998F33176E2F24C8E0ACB9AA84' } + @{ Path = 'lib\sqlite\SQLitePCLRaw.provider.e_sqlite3.dll'; Sha256 = '0BC1D8536C480C3FE66E8AD6DC796073FBE287961781F73A54AD13E59DF1359A' } + @{ Path = 'lib\sqlite\SQLitePCLRaw.batteries_v2.dll'; Sha256 = '815CE410C9EAB531DD219318E3BA16C5E38E83D361CFBBDBB4BBCA222C497B0B' } + @{ Path = 'lib\sqlite\runtimes\win-x64\e_sqlite3.dll'; Sha256 = 'B7385D722C83FB52142A00477A726723745916D22A555711EE89834C1111FB2E' } + @{ Path = 'lib\sqlite\runtimes\win-arm64\e_sqlite3.dll'; Sha256 = '4177542756BD94F75B55FB74367E0CF34E66A88FFAF95755489AF14C6CA2F792' } + @{ Path = 'templates\Report.template.html'; Sha256 = 'C72DF516F2B7A9A33361D1A917FAB53EDE9E1E4E2F2FAC3BC7D2915311631DA0' } + @{ Path = 'docs\PurviewDlpReport-Guide.html'; Sha256 = 'C768E1DB2249DC13CDAF68B48F75563AB3490D8BF8349A2B379770B758F88510' } + @{ Path = 'docs\PurviewDlpReport-UserGuide.html'; Sha256 = 'E9FA2549C58A32BF56ED337208946CAFBEC12D538C0D62C8C478A3DE0A6DC534' } + @{ Path = 'README.md'; Sha256 = '80052963DA668786762A414B19C1C483EBE09DE51318B584C326B5D3FFDFD146' } + @{ Path = 'THIRD-PARTY-NOTICES.md'; Sha256 = '741F9DE1035CDC64DAA2FCEB54A9A4CFD6D829B5E7903A4E9CA56EF8CF050A0B' } + ) +} + +# SIG # Begin signature block +# MIIghQYJKoZIhvcNAQcCoIIgdjCCIHICAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCb5opeyKCvB3fR +# 6UGTNLJfX3Qv3PLfvtYspCLlt8mVsqCCDW8wggZyMIIEWqADAgECAghkM1HTxzif +# CDANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +# EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8G +# A1UEAwwoU1NMLmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTAe +# Fw0xNjA2MjQyMDQ0MzBaFw0zMTA2MjQyMDQ0MzBaMHgxCzAJBgNVBAYTAlVTMQ4w +# DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjERMA8GA1UECgwIU1NMIENv +# cnAxNDAyBgNVBAMMK1NTTC5jb20gQ29kZSBTaWduaW5nIEludGVybWVkaWF0ZSBD +# QSBSU0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCfgxNzqrDG +# bSHL24t6h3TQcdyOl3Ka5LuINLTdgAPGL0WkdJq/Hg9Q6p5tePOf+lEmqT2d0bKU +# Vz77OYkbkStW72fL5gvjDjmMxjX0jD3dJekBrBdCfVgWQNz51ShEHZVkMGE6ZPKX +# 13NMfXsjAm3zdetVPW+qLcSvvnSsXf5qtvzqXHnpD0OctVIFD+8+sbGP0EmtpuNC +# GVQ/8y8Ooct8/hP5IznaJRy4PgBKOm8yMDdkHseudQfYVdIYyQ6KvKNc8HwKp4WB +# wg6vj5lc02AlvINaaRwlE81y9eucgJvcLGfE3ckJmNVz68Qho+Uyjj4vUpjGYDdk +# jLJvSlRyGMwnh/rNdaJjIUy1PWT9K6abVa8mTGC0uVz+q0O9rdATZlAfC9KJpv/X +# gAbxwxECMzNhF/dWH44vO2jnFfF3VkopngPawismYTJboFblSSmNNqf1x1KiVgMg +# Lzh4gL32Bq5BNMuURb2bx4kYHwu6/6muakCZE93vUN8BuvIE1tAx3zQ4XldbyDge +# VtSsSKbt//m4wTvtwiS+RGCnd83VPZhZtEPqqmB9zcLlL/Hr9dQg1Zc0bl0EawUR +# 0tOSjAknRO1PNTFGfnQZBWLsiePqI3CY5NEv1IoTGEaTZeVYc9NMPSd6Ij/D+KNV +# t/nmh4LsRR7Fbjp8sU65q2j3m2PVkUG8qQIDAQABo4H7MIH4MA8GA1UdEwEB/wQF +# MAMBAf8wHwYDVR0jBBgwFoAU3QQJB6L1en1SUxKSle44gCUNplkwMAYIKwYBBQUH +# AQEEJDAiMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcHMuc3NsLmNvbTARBgNVHSAE +# CjAIMAYGBFUdIAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwOwYDVR0fBDQwMjAwoC6g +# LIYqaHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0G +# A1UdDgQWBBRUwv4QlQCTzWr158DX2bJLuI8M4zAOBgNVHQ8BAf8EBAMCAYYwDQYJ +# KoZIhvcNAQELBQADggIBAPUPJodwr5miyvXWyfCNZj05gtOII9iCv49UhCe204MH +# 154niU2EjlTRIO5gQ9tXQjzHsJX2vszqoz2OTwbGK1mGf+tzG8rlQCbgPW/M9r1x +# xs19DiBAOdYF0q+UCL9/wlG3K7V7gyHwY9rlnOFpLnUdTsthHvWlM98CnRXZ7WmT +# V7pGRS6AvGW+5xI+3kf/kJwQrfZWsqTU+tb8LryXIbN2g9KR+gZQ0bGAKID+260P +# Z+34fdzZcFt6umi1s0pmF4/n8OdX3Wn+vF7h1YyfE7uVmhX7eSuF1W0+Z0duGwdc +# +1RFDxYRLhHDsLy1bhwzV5Qe/kI0Ro4xUE7bM1eV+jjk5hLbq1guRbfZIsr0WkdJ +# LCjoT4xCPGRo6eZDrBmRqccTgl/8cQo3t51Qezxd96JSgjXktefTCm9r/o35pNfV +# HUvnfWII+NnXrJlJ27WEQRQu9i5gl1NLmv7xiHp0up516eDap8nMLDt7TAp4z5T3 +# NmC2gzyKVMtODWgqlBF1JhTqIDfM63kXdlV4cW3iSTgzN9vkbFnHI2LmvM4uVEv9 +# XgMqyN0eS3FE0HU+MWJliymm7STheh2ENH+kF3y0rH0/NVjLw78a3Z9UVm1F5VPz +# iIorMaPKPlDRADTsJwjDZ8Zc6Gi/zy4WZbg8Zv87spWrmo2dzJTw7XhQf+xkR6Od +# MIIG9TCCBN2gAwIBAgIQBof4ljXihJpmCqqt2mhAWzANBgkqhkiG9w0BAQsFADB4 +# MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24x +# ETAPBgNVBAoMCFNTTCBDb3JwMTQwMgYDVQQDDCtTU0wuY29tIENvZGUgU2lnbmlu +# ZyBJbnRlcm1lZGlhdGUgQ0EgUlNBIFIxMB4XDTI2MTAxMzAyNDgwMloXDTI3MTAx +# MzAyNDgwMlowXDELMAkGA1UEBhMCRlIxGzAZBgNVBAcMEkNoYW50ZWxvdXAtRW4t +# QnJpZTEXMBUGA1UECgwOTmljb2xhcyBGYWJlcnQxFzAVBgNVBAMMDk5pY29sYXMg +# RmFiZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsg6CT0mFQ4jB +# V2plIS4uWFSoVqRHd82Y7XYZ4CUTe/7nPBRUlXXdKTZMMHzGgiG8dEXNE8K1Xe90 +# Dgl/FnorzAFlrNWSwf6t27UAUw9xkqtRDVVpVgpixpWkAwjcQeLG+YMqBkGbFi35 +# TGvS23lPFWvLp4DxARrv15MtZ/NjNTCAAGUlMP2hFVBknEmrnYjEGyeJ93sl20RS +# WXQQLNqlenBdkGCrQW4XjHtRjJAEqAC68hN6nWevopSQ8CLUV7A1RFfHFarDrzrL +# zr0/NjIdSyIfM9hShln31NDrktB6Ups2XIyrtPDNB7fjkW4jpLlOqQGfYKsU9ErE +# 4SnFsh0yOOjitegyh1RvZ9wH++3DF4CmJfTli5l/gEIm8xCCryxI+K5EogW3wu1y +# u8NT2hcvHjdh7OpRyPyFAuLZ8MfX7kPAJpnuCeOuktzY/20eVD8bDglj+4zgqzsn +# cR/t6T/7w1/HxpRyrVoKvEyRx+mphYh5jMdEa0BjxOL0vAJpdGZSfXGmPm/yPqRn +# 5STTkMtzkVQXPbcvgKM0luzFcQKPHk3qdHLEXa8gf93ktYDbLE5Wd2UHrXz3SZxN +# URHPdIXCr1qgrqQve7041kMpzrkDI5DnG2lvRG4108gp02bHG0muKyZlDdCOra7h +# Zonu2nO79sFallruzzNi0o994PoWksUCAwEAAaOCAZUwggGRMAwGA1UdEwEB/wQC +# MAAwHwYDVR0jBBgwFoAUVML+EJUAk81q9efA19myS7iPDOMwegYIKwYBBQUHAQEE +# bjBsMEgGCCsGAQUFBzAChjxodHRwOi8vY2VydC5zc2wuY29tL1NTTGNvbS1TdWJD +# QS1Db2RlU2lnbmluZy1SU0EtNDA5Ni1SMS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6 +# Ly9vY3Nwcy5zc2wuY29tMFEGA1UdIARKMEgwCAYGZ4EMAQQBMDwGDCsGAQQBgqkw +# AQMDATAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3JlcG9zaXRv +# cnkwEwYDVR0lBAwwCgYIKwYBBQUHAwMwTQYDVR0fBEYwRDBCoECgPoY8aHR0cDov +# L2NybHMuc3NsLmNvbS9TU0xjb20tU3ViQ0EtQ29kZVNpZ25pbmctUlNBLTQwOTYt +# UjEuY3JsMB0GA1UdDgQWBBRoUx3630l9wA1fUpkKkw2GUIxl/jAOBgNVHQ8BAf8E +# BAMCB4AwDQYJKoZIhvcNAQELBQADggIBAE3oj6cNuwlDToi1BwjgsA5djfRN4DIi +# BYDFZIRYaIRe/rEdnItz3aqQQdFuwXnJA/MQEmi2WAGek8GeZF+R41s8FbcDxBUE +# BoSt98TuBE+0xgxQWDiPhhXaX1Aw9V0GF4ZnlfCSw1eu2KiASaKn7Bo0PLbEAXff +# vftRXof/kyqtw8iID6I4Rhrf5DI3WVZQodakUUmWnbnziWvEZOWbNOfzwP+7BwEt +# 7jQANfbmEfb4p75rfy5dQVwniTDSvdo6xkrbe1Cr93GS7cJta18IG5CoIVUmdK+s +# W38B1sLuy8Mi0MstMXV3004yaRuzLdNaAM0f9WAFerxemiQC0kdmA44/lWV3lVkM +# WJSwkHBkkSg8Eh0hxcGzTH2xmtDhLJHNNLRwkI9u2Ur+HbLCt50oBgSV8BcCs/1i +# qi884iC/yuX+HIv9YgTUNZ8c4lLaq4VuliJpXLu2buHIsTkO0v0+5oCM91zjTVZm +# ULeH38N0Vr83psiJVuBSxQyt5nRVrpYz7FACq5gtXELkyViq/rGOlJKaRrgQ6WWq +# eoIXqKhVFAdP/jAEm2M8ezoVYsUgPyKY2OcuecV9coulAcxUIUydx6qYiOfsGj5u +# Dy7l4aYjvB771wkUMzUQwxzcDYXJtMMTaWRe6n9EemR67jc73u5CJyFg3PfA5WR1 +# wZislcdCZuS6MYISbDCCEmgCAQEwgYwweDELMAkGA1UEBhMCVVMxDjAMBgNVBAgM +# BVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDE0MDIG +# A1UEAwwrU1NMLmNvbSBDb2RlIFNpZ25pbmcgSW50ZXJtZWRpYXRlIENBIFJTQSBS +# MQIQBof4ljXihJpmCqqt2mhAWzANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3 +# AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisG +# AQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCWAcZQD6XY +# ACofUhCqEPpSZ6wRji8uX0aUZ6A6WmKO1zANBgkqhkiG9w0BAQEFAASCAgCB2TGZ +# /v6H57WZNSUqrfIo4emcaP5BjR0iU2AvZ6+KbzLe9M+7ptdlCA+dx7qu7ZOhJDWP +# dGLmMGh/1kMNg6R9lVmYb2Kt+PQ0lFgqDXdLrCbxG7wLZpSf74rfzz10nCSh+ki+ +# yk1ufh02SU2TAmR/wK69cf8fgRozhOFdehMzVnBn1pBKAP9TJqtwNcm0pX8MHV7g +# 69nNTUriwKxZakdhe6VsFzrsr/5pnlRkFTCirlQcf1RUbmSEtC9IMYK3TB2yDbH3 +# aIee6oALmx3a9DBGUGhjSI6am94ucVhwNN/pUTz+jxArntzcRWyR6TrnYLkWDJkV +# AGWkxwmjMV1ic12nlDUueDC8D8/wnGk8tLL2JH9AuEqKerpbezOHkwjp9OmDMxs0 +# qN5jJkVs22tSGNydA8Q54E8EaD5u96ICpjj7Njk0qRISpteRPN8q0RDLZuulZEnl +# qBlBC9E4rJA0E0a7OLLSycLX4dw8K9jgsOWhktEyvws/yFvienOEMN+zmgw0wGUP +# /Un2B5h6nhUwBKV/tz6ft/OBA8+RYaJdShBwN0+EhPCS3fFUq2xmLSkvcYJAuBMX +# ySlrqHHCP08GJ67njial4Jup9IJjuaBJwmJckv1hyHn1+KDO/tv4dWCHKaD2vqyG +# LvI3cnsu9/oXGQZVT2T7SiJr4K4lHG19hVJYVqGCDykwgg8lBgorBgEEAYI3AwMB +# MYIPFTCCDxEGCSqGSIb3DQEHAqCCDwIwgg7+AgEDMQ0wCwYJYIZIAWUDBAIBMIGJ +# BgsqhkiG9w0BCRABBKB6BHgwdgIBAQYMKwYBBAGCqTABAwYBMDEwDQYJYIZIAWUD +# BAIBBQAEIAuOM6kQYhuwNmGJ3h9kK7LHiAezMbxUN5/mqTsS5LogAggI3E23Jb9l +# +hgPMjAyNjEwMTMxMTUzNTVaMAMCAQECEAG2Tt6l/muiHq4IkADnox+gggwAMIIE +# /DCCAuSgAwIBAgIQH2sWYtIuG2xd8cDBoGAOODANBgkqhkiG9w0BAQsFADBzMQsw +# CQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24xETAP +# BgNVBAoMCFNTTCBDb3JwMS8wLQYDVQQDDCZTU0wuY29tIFRpbWVzdGFtcGluZyBJ +# c3N1aW5nIFJTQSBDQSBSMTAeFw0yNTAyMTgxNjMyMDJaFw0zNDExMTIxODUwMDVa +# MG4xCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3Rv +# bjERMA8GA1UECgwIU1NMIENvcnAxKjAoBgNVBAMMIVNTTC5jb20gVGltZXN0YW1w +# aW5nIFVuaXQgMjAyNSBFMTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBv7UVHH +# b+ZVluxPXlfE3M6tg0Xnq8dic+O3vPOCRpalUM1vO9A+GRzSVVjyygHhYrBw62XL +# Fh1kv7e+yRd/aTajggFaMIIBVjAfBgNVHSMEGDAWgBQMnRAljpqnG5mHQ88IfuG9 +# gZD0zzBRBggrBgEFBQcBAQRFMEMwQQYIKwYBBQUHMAKGNWh0dHA6Ly9jZXJ0LnNz +# bC5jb20vU1NMLmNvbS10aW1lU3RhbXBpbmctSS1SU0EtUjEuY2VyMFEGA1UdIARK +# MEgwPAYMKwYBBAGCqTABAwYBMCwwKgYIKwYBBQUHAgEWHmh0dHBzOi8vd3d3LnNz +# bC5jb20vcmVwb3NpdG9yeTAIBgZngQwBBAIwFgYDVR0lAQH/BAwwCgYIKwYBBQUH +# AwgwRgYDVR0fBD8wPTA7oDmgN4Y1aHR0cDovL2NybHMuc3NsLmNvbS9TU0wuY29t +# LXRpbWVTdGFtcGluZy1JLVJTQS1SMS5jcmwwHQYDVR0OBBYEFM582cAEgMUkEGoJ +# 6hyrJT0R/ajSMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAgHN1 +# JIW9ZlNip07FfY9r1LJwLKTl1h/y9r1BjDgHMEz7U7J7mAy32JxA1KLyjCCJqNku +# RfhDHGFEfZtjTOru7AQMDSDiUDvCCc3jaz5py9jGvwIe5npkLbXqXhA+SqCzG719 +# USigwhHkYr7v8NiXoaY0Xu03p0BiZZreZlpngmGB9+86N94FqIFx2I5Z5iuu7d4j +# 7aAOZBNxfU9jFDIIGDtWfWeCpF0Q8rLE/3DHDlzJKs5Il3i/VoOl7rTU938oSrxM +# www6GDBlKX8rGHxvkjgIb9MXkVglJSDHsNAZDeCS/inaxKHA+ChuB8K4OxIxa6k1 +# e+eHBypakAY3wuN7w7PlPmhN6k5IdqZn3HZHs2VjoR841Z9wmVPFdGDdkBZ7XOmb +# 5OZUaKufsvtER0VbY2DkzaqAaItt1kc+I+FUz7PiVz2PUzpegkftRRxvrPyIL08b +# lJTnbMQ0XrqDN0rEfuTLv4XhhnKyJaXhxCUdjO/cRumMzZ35QTUMItBWy1xGp0iy +# oFVnAya2pPsVMuAI+sy1zxlOS9l5iSxvKJ8gpnpOgjqYTa7u1eyZo+4JlcGoiiR1 +# 7LthMTrF1q62tO5xOpI8txUZ6gtKGtkJV6wdj+vZLcwKZ2M93GqVpOo6UBlGjx1s +# KsVZ7AIUzJZli4NJ0M2KYcrfNnlebkYKpDnSqWwwggb8MIIE5KADAgECAhBtUhhw +# h+gjTYVgANCAj5NWMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMQ4wDAYD +# VQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBv +# cmF0aW9uMTEwLwYDVQQDDChTU0wuY29tIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRo +# b3JpdHkgUlNBMB4XDTE5MTExMzE4NTAwNVoXDTM0MTExMjE4NTAwNVowczELMAkG +# A1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYD +# VQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNvbSBUaW1lc3RhbXBpbmcgSXNz +# dWluZyBSU0EgQ0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCu +# URAT0vk8IKAghd7JUBxkyeH9xek0/wp/MUjoclrFXqhh/fGH91Fc+7fm0MHCE7A+ +# wmOiqBj9ODrJAYGq3rm33jCnHSsCBNWAQYyoauLq8IjqsS1JlXL29qDNMMdwZ8UN +# zQS7vWZMDJ40JSGNphMGTIA2qn2bohGtgRc4p1395ESypUOaGvJ3t0FNL3BuKmb6 +# YctMcQUF2sqooMzd89h0E6ujdvBDo6ZwNnWoxj7YmfWjSXg33A5GuY9ym4QZM5OE +# Vgo8ebz/B+gyhyCLNNhh4Mb/4xvCTCMVmNYrBviGgdPZYrym8Zb84TQCmSuX0JlL +# La6WK1aO6qlwISbb9bVGh866ekKblC/XRP20gAu1CjvcYciUgNTrGFg8f8AJgQPO +# Cc1/CCdaJSYwhJpSdheKOnQgESgNmYZPhFOC6IKaMAUXk5U1tjTcFCgFvvArXtK4 +# azAWUOO1Y3fdldIBL6LjkzLUCYJNkFXqhsBVcPMuB0nUDWvLJfPimstjJ8lF4S6E +# CxWnlWi7OElVwTnt1GtRqeY9ydvvGLntU+FecK7DbqHDUd366UreMkSBtzevAc9a +# qoZPnjVMjvFqV1pYOjzmTiVHZtAc80bAfFe5LLfJzPI6DntNyqobpwTevQpHqPDN +# 9qqNO83r3kaw8A9j+HZiSw2AX5cGdQP0kG0vhzfgBwIDAQABo4IBgTCCAX0wEgYD +# VR0TAQH/BAgwBgEB/wIBADAfBgNVHSMEGDAWgBTdBAkHovV6fVJTEpKV7jiAJQ2m +# WTCBgwYIKwYBBQUHAQEEdzB1MFEGCCsGAQUFBzAChkVodHRwOi8vd3d3LnNzbC5j +# b20vcmVwb3NpdG9yeS9TU0xjb21Sb290Q2VydGlmaWNhdGlvbkF1dGhvcml0eVJT +# QS5jcnQwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3Nwcy5zc2wuY29tMD8GA1UdIAQ4 +# MDYwNAYEVR0gADAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3Jl +# cG9zaXRvcnkwEwYDVR0lBAwwCgYIKwYBBQUHAwgwOwYDVR0fBDQwMjAwoC6gLIYq +# aHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0GA1Ud +# DgQWBBQMnRAljpqnG5mHQ88IfuG9gZD0zzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +# hvcNAQELBQADggIBAJIZdQ2mWkLPGQfZ8vyU+sCb8BXpRJZaL3Ez3VDlE3uZk3cP +# xPtybVfLuqaci0W6SB22JTMttCiQMnIVOsXWnIuAbD/aFTcUkTLBI3xys+wEajzX +# aXJYWACDS47BRjDtYlDW14gLJxf8W6DQoH3jHDGGy8kGJFOlDKG7/YrK7UGfHtBA +# EDVe6lyZ+FtCsrk7dD/IiL/+Q3Q6SFASJLQ2XI89ihFugdYL77CiDNXrI2MFspQG +# swXEAGpHuaQDTHUp/LdR3TyrIsLlnzoLskUGswF/KF8+kpWUiKJNC4rPWtNrxlbX +# YRGgdEdx8SMjUTDClldcrknlFxbqHsVmr9xkT2QtFmG+dEq1v5fsIK0vHaHrWjMM +# maJ9i+4qGJSD0stYfQ6v0PddT7EpGxGd867Ada6FZyHwbuQSadMb0K0P0OC2r7rw +# qBUe0BaMqTa6LWzWItgBjGcObXeMxmbQqlEz2YtAcErkZvh0WABDDE4U8GyV/32F +# daAvJgTfe9MiL2nSBioYe/g5mHUSWAay/Ip1RQmQCvmF9sNfqlhJwkjy/1U1ibUk +# TIUBX3HgymyQvqQTZLLys6pL2tCdWcjI9YuLw30rgZm8+K387L7ycUvqrmQ3ZJlu +# jHl3r1hgV76s3WwMPgKk1bAEFMj+rRXimSC+Ev30hXZdqyMdl/il5Ksd0vhGMYIC +# WDCCAlQCAQEwgYcwczELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD +# VQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNv +# bSBUaW1lc3RhbXBpbmcgSXNzdWluZyBSU0EgQ0EgUjECEB9rFmLSLhtsXfHAwaBg +# DjgwCwYJYIZIAWUDBAIBoIIBYTAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQw +# HAYJKoZIhvcNAQkFMQ8XDTI2MTAxMzExNTM1NVowKAYJKoZIhvcNAQk0MRswGTAL +# BglghkgBZQMEAgGhCgYIKoZIzj0EAwIwLwYJKoZIhvcNAQkEMSIEINovpaNsLpV5 +# YyJJz9Vi1wWytWU3zwvWZnpGCRUv79RyMIHJBgsqhkiG9w0BCRACLzGBuTCBtjCB +# szCBsAQgVCr5oWqNci5mEUl4iumUwYqaruWmXLNEolSa+Wx5x4swgYswd6R1MHMx +# CzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjER +# MA8GA1UECgwIU1NMIENvcnAxLzAtBgNVBAMMJlNTTC5jb20gVGltZXN0YW1waW5n +# IElzc3VpbmcgUlNBIENBIFIxAhAfaxZi0i4bbF3xwMGgYA44MAoGCCqGSM49BAMC +# BEcwRQIgSsFblyXgUid/CG0E1CaXMKpddApLfnqShZaf8CKJ3+MCIQDWCgVqyeqD +# 1V7yWB9tndFA6KoX6pdm+UztfW2HDJivDg== +# SIG # End signature block diff --git a/package/PurviewDlpReport.psd1 b/package/PurviewDlpReport.psd1 index 02a94c6..95cdc96 100644 --- a/package/PurviewDlpReport.psd1 +++ b/package/PurviewDlpReport.psd1 @@ -1,28 +1,204 @@ -# -# Purview DLP Report - module manifest -# -------------------------------------------------------------------------- -# Author : Nicolas Fabert -# Version : see ModuleVersion -# -# Loaded by Invoke-PurviewDlpReport.ps1 (Import-Module by path). -# -@{ - RootModule = 'PurviewDlpReport.psm1' - ModuleVersion = '2.1.1' - GUID = 'de838866-574a-439e-b11d-08fd9239e1b2' - Author = 'Nicolas Fabert' - Description = 'Purview DLP Report: collects Exchange Online DLP rule matches from Activity Explorer into a local SQLite database and produces CSV/HTML reports (one row per Message ID).' - PowerShellVersion = '7.4' - - # Functions called by Invoke-PurviewDlpReport.ps1 and by the tests. The other functions stay internal - # to the module: add a function here only when the script or a test calls it. - FunctionsToExport = @( - 'Import-DlpConfiguration', 'Initialize-DlpEngine', 'Open-DlpStore', 'Enter-DlpLock', 'Exit-DlpLock' - 'Start-DlpLog', 'Stop-DlpLog', 'Write-DlpLog' - 'Write-DlpBanner', 'Write-DlpStep', 'Write-DlpItem', 'Write-DlpSummary' - 'Format-DlpNumber', 'Format-DlpDuration', 'Format-DlpBytes', 'Format-DlpRange' - 'Get-DlpTimeZone', 'Resolve-DlpPeriod', 'New-DlpRangeList', 'Get-DlpCollectionPlan', 'Get-DlpCoverage', 'Get-DlpExpiringGaps' - 'Connect-DlpActivityExplorer', 'Disconnect-DlpActivityExplorer', 'Test-DlpPageEnvelope', 'Invoke-DlpCollection' - 'New-DlpReport', 'Show-DlpStatus', 'Invoke-DlpRetention' - ) -} \ No newline at end of file +# +# Purview DLP Report - module manifest +# -------------------------------------------------------------------------- +# Author : Nicolas Fabert +# Version : see ModuleVersion +# +# Loaded by Invoke-PurviewDlpReport.ps1 (Import-Module by path). +# +@{ + RootModule = 'PurviewDlpReport.psm1' + ModuleVersion = '2.1.2' + GUID = 'de838866-574a-439e-b11d-08fd9239e1b2' + Author = 'Nicolas Fabert' + Description = 'Purview DLP Report: collects Exchange Online DLP rule matches from Activity Explorer into a local SQLite database and produces CSV/HTML reports (one row per Message ID).' + PowerShellVersion = '7.4' + + # Functions called by Invoke-PurviewDlpReport.ps1 and by the tests. The other functions stay internal + # to the module: add a function here only when the script or a test calls it. + FunctionsToExport = @( + 'Import-DlpConfiguration', 'Initialize-DlpEngine', 'Open-DlpStore', 'Enter-DlpLock', 'Exit-DlpLock' + 'Start-DlpLog', 'Stop-DlpLog', 'Write-DlpLog' + 'Write-DlpBanner', 'Write-DlpStep', 'Write-DlpItem', 'Write-DlpSummary' + 'Format-DlpNumber', 'Format-DlpDuration', 'Format-DlpBytes', 'Format-DlpRange' + 'Get-DlpTimeZone', 'Resolve-DlpPeriod', 'New-DlpRangeList', 'Get-DlpCollectionPlan', 'Get-DlpCoverage', 'Get-DlpExpiringGaps' + 'Connect-DlpActivityExplorer', 'Disconnect-DlpActivityExplorer', 'Test-DlpPageEnvelope', 'Invoke-DlpCollection', 'Write-DlpCollectionDiagnostics' + 'New-DlpReport', 'Show-DlpStatus', 'Invoke-DlpRetention' + ) +} +# SIG # Begin signature block +# MIIghgYJKoZIhvcNAQcCoIIgdzCCIHMCAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCj87exg9qePpxz +# MYqxDEGaZ3EOWR1li3monxfJWU5teKCCDW8wggZyMIIEWqADAgECAghkM1HTxzif +# CDANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +# EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8G +# A1UEAwwoU1NMLmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTAe +# Fw0xNjA2MjQyMDQ0MzBaFw0zMTA2MjQyMDQ0MzBaMHgxCzAJBgNVBAYTAlVTMQ4w +# DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjERMA8GA1UECgwIU1NMIENv +# cnAxNDAyBgNVBAMMK1NTTC5jb20gQ29kZSBTaWduaW5nIEludGVybWVkaWF0ZSBD +# QSBSU0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCfgxNzqrDG +# bSHL24t6h3TQcdyOl3Ka5LuINLTdgAPGL0WkdJq/Hg9Q6p5tePOf+lEmqT2d0bKU +# Vz77OYkbkStW72fL5gvjDjmMxjX0jD3dJekBrBdCfVgWQNz51ShEHZVkMGE6ZPKX +# 13NMfXsjAm3zdetVPW+qLcSvvnSsXf5qtvzqXHnpD0OctVIFD+8+sbGP0EmtpuNC +# GVQ/8y8Ooct8/hP5IznaJRy4PgBKOm8yMDdkHseudQfYVdIYyQ6KvKNc8HwKp4WB +# wg6vj5lc02AlvINaaRwlE81y9eucgJvcLGfE3ckJmNVz68Qho+Uyjj4vUpjGYDdk +# jLJvSlRyGMwnh/rNdaJjIUy1PWT9K6abVa8mTGC0uVz+q0O9rdATZlAfC9KJpv/X +# gAbxwxECMzNhF/dWH44vO2jnFfF3VkopngPawismYTJboFblSSmNNqf1x1KiVgMg +# Lzh4gL32Bq5BNMuURb2bx4kYHwu6/6muakCZE93vUN8BuvIE1tAx3zQ4XldbyDge +# VtSsSKbt//m4wTvtwiS+RGCnd83VPZhZtEPqqmB9zcLlL/Hr9dQg1Zc0bl0EawUR +# 0tOSjAknRO1PNTFGfnQZBWLsiePqI3CY5NEv1IoTGEaTZeVYc9NMPSd6Ij/D+KNV +# t/nmh4LsRR7Fbjp8sU65q2j3m2PVkUG8qQIDAQABo4H7MIH4MA8GA1UdEwEB/wQF +# MAMBAf8wHwYDVR0jBBgwFoAU3QQJB6L1en1SUxKSle44gCUNplkwMAYIKwYBBQUH +# AQEEJDAiMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcHMuc3NsLmNvbTARBgNVHSAE +# CjAIMAYGBFUdIAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwOwYDVR0fBDQwMjAwoC6g +# LIYqaHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0G +# A1UdDgQWBBRUwv4QlQCTzWr158DX2bJLuI8M4zAOBgNVHQ8BAf8EBAMCAYYwDQYJ +# KoZIhvcNAQELBQADggIBAPUPJodwr5miyvXWyfCNZj05gtOII9iCv49UhCe204MH +# 154niU2EjlTRIO5gQ9tXQjzHsJX2vszqoz2OTwbGK1mGf+tzG8rlQCbgPW/M9r1x +# xs19DiBAOdYF0q+UCL9/wlG3K7V7gyHwY9rlnOFpLnUdTsthHvWlM98CnRXZ7WmT +# V7pGRS6AvGW+5xI+3kf/kJwQrfZWsqTU+tb8LryXIbN2g9KR+gZQ0bGAKID+260P +# Z+34fdzZcFt6umi1s0pmF4/n8OdX3Wn+vF7h1YyfE7uVmhX7eSuF1W0+Z0duGwdc +# +1RFDxYRLhHDsLy1bhwzV5Qe/kI0Ro4xUE7bM1eV+jjk5hLbq1guRbfZIsr0WkdJ +# LCjoT4xCPGRo6eZDrBmRqccTgl/8cQo3t51Qezxd96JSgjXktefTCm9r/o35pNfV +# HUvnfWII+NnXrJlJ27WEQRQu9i5gl1NLmv7xiHp0up516eDap8nMLDt7TAp4z5T3 +# NmC2gzyKVMtODWgqlBF1JhTqIDfM63kXdlV4cW3iSTgzN9vkbFnHI2LmvM4uVEv9 +# XgMqyN0eS3FE0HU+MWJliymm7STheh2ENH+kF3y0rH0/NVjLw78a3Z9UVm1F5VPz +# iIorMaPKPlDRADTsJwjDZ8Zc6Gi/zy4WZbg8Zv87spWrmo2dzJTw7XhQf+xkR6Od +# MIIG9TCCBN2gAwIBAgIQBof4ljXihJpmCqqt2mhAWzANBgkqhkiG9w0BAQsFADB4 +# MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24x +# ETAPBgNVBAoMCFNTTCBDb3JwMTQwMgYDVQQDDCtTU0wuY29tIENvZGUgU2lnbmlu +# ZyBJbnRlcm1lZGlhdGUgQ0EgUlNBIFIxMB4XDTI2MTAxMzAyNDgwMloXDTI3MTAx +# MzAyNDgwMlowXDELMAkGA1UEBhMCRlIxGzAZBgNVBAcMEkNoYW50ZWxvdXAtRW4t +# QnJpZTEXMBUGA1UECgwOTmljb2xhcyBGYWJlcnQxFzAVBgNVBAMMDk5pY29sYXMg +# RmFiZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsg6CT0mFQ4jB +# V2plIS4uWFSoVqRHd82Y7XYZ4CUTe/7nPBRUlXXdKTZMMHzGgiG8dEXNE8K1Xe90 +# Dgl/FnorzAFlrNWSwf6t27UAUw9xkqtRDVVpVgpixpWkAwjcQeLG+YMqBkGbFi35 +# TGvS23lPFWvLp4DxARrv15MtZ/NjNTCAAGUlMP2hFVBknEmrnYjEGyeJ93sl20RS +# WXQQLNqlenBdkGCrQW4XjHtRjJAEqAC68hN6nWevopSQ8CLUV7A1RFfHFarDrzrL +# zr0/NjIdSyIfM9hShln31NDrktB6Ups2XIyrtPDNB7fjkW4jpLlOqQGfYKsU9ErE +# 4SnFsh0yOOjitegyh1RvZ9wH++3DF4CmJfTli5l/gEIm8xCCryxI+K5EogW3wu1y +# u8NT2hcvHjdh7OpRyPyFAuLZ8MfX7kPAJpnuCeOuktzY/20eVD8bDglj+4zgqzsn +# cR/t6T/7w1/HxpRyrVoKvEyRx+mphYh5jMdEa0BjxOL0vAJpdGZSfXGmPm/yPqRn +# 5STTkMtzkVQXPbcvgKM0luzFcQKPHk3qdHLEXa8gf93ktYDbLE5Wd2UHrXz3SZxN +# URHPdIXCr1qgrqQve7041kMpzrkDI5DnG2lvRG4108gp02bHG0muKyZlDdCOra7h +# Zonu2nO79sFallruzzNi0o994PoWksUCAwEAAaOCAZUwggGRMAwGA1UdEwEB/wQC +# MAAwHwYDVR0jBBgwFoAUVML+EJUAk81q9efA19myS7iPDOMwegYIKwYBBQUHAQEE +# bjBsMEgGCCsGAQUFBzAChjxodHRwOi8vY2VydC5zc2wuY29tL1NTTGNvbS1TdWJD +# QS1Db2RlU2lnbmluZy1SU0EtNDA5Ni1SMS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6 +# Ly9vY3Nwcy5zc2wuY29tMFEGA1UdIARKMEgwCAYGZ4EMAQQBMDwGDCsGAQQBgqkw +# AQMDATAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3JlcG9zaXRv +# cnkwEwYDVR0lBAwwCgYIKwYBBQUHAwMwTQYDVR0fBEYwRDBCoECgPoY8aHR0cDov +# L2NybHMuc3NsLmNvbS9TU0xjb20tU3ViQ0EtQ29kZVNpZ25pbmctUlNBLTQwOTYt +# UjEuY3JsMB0GA1UdDgQWBBRoUx3630l9wA1fUpkKkw2GUIxl/jAOBgNVHQ8BAf8E +# BAMCB4AwDQYJKoZIhvcNAQELBQADggIBAE3oj6cNuwlDToi1BwjgsA5djfRN4DIi +# BYDFZIRYaIRe/rEdnItz3aqQQdFuwXnJA/MQEmi2WAGek8GeZF+R41s8FbcDxBUE +# BoSt98TuBE+0xgxQWDiPhhXaX1Aw9V0GF4ZnlfCSw1eu2KiASaKn7Bo0PLbEAXff +# vftRXof/kyqtw8iID6I4Rhrf5DI3WVZQodakUUmWnbnziWvEZOWbNOfzwP+7BwEt +# 7jQANfbmEfb4p75rfy5dQVwniTDSvdo6xkrbe1Cr93GS7cJta18IG5CoIVUmdK+s +# W38B1sLuy8Mi0MstMXV3004yaRuzLdNaAM0f9WAFerxemiQC0kdmA44/lWV3lVkM +# WJSwkHBkkSg8Eh0hxcGzTH2xmtDhLJHNNLRwkI9u2Ur+HbLCt50oBgSV8BcCs/1i +# qi884iC/yuX+HIv9YgTUNZ8c4lLaq4VuliJpXLu2buHIsTkO0v0+5oCM91zjTVZm +# ULeH38N0Vr83psiJVuBSxQyt5nRVrpYz7FACq5gtXELkyViq/rGOlJKaRrgQ6WWq +# eoIXqKhVFAdP/jAEm2M8ezoVYsUgPyKY2OcuecV9coulAcxUIUydx6qYiOfsGj5u +# Dy7l4aYjvB771wkUMzUQwxzcDYXJtMMTaWRe6n9EemR67jc73u5CJyFg3PfA5WR1 +# wZislcdCZuS6MYISbTCCEmkCAQEwgYwweDELMAkGA1UEBhMCVVMxDjAMBgNVBAgM +# BVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDE0MDIG +# A1UEAwwrU1NMLmNvbSBDb2RlIFNpZ25pbmcgSW50ZXJtZWRpYXRlIENBIFJTQSBS +# MQIQBof4ljXihJpmCqqt2mhAWzANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3 +# AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisG +# AQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCBcR4Ar/bm +# AK1H3GcHjraCvT9MwZVyujwDR7Eje3rqJzANBgkqhkiG9w0BAQEFAASCAgANZzz2 +# a2ExJI0ppCKx3k8AM4IOB8H9ET2n5wT+N0Bpb1Wsha6opCXiRY62YqXuBwblMWlM +# jGmyGON1j9Cwvnb1KbNQITsW+/Vj0tZSi6OfubUtdzpMxuggsS4WZfmHo1z3CoJw +# eiSldeyUhuzy1OVnNQKW3uDUH11Y1sMZ1NI9+KmxpppOE4WeKqjtr9zOuheuEgPk +# I4p7yARiuqAt/6na6dTtcK9dKhpSyi1rZYqgp+1s6zaSYGYNnRtedCSZKUIDr1Lr +# 2M6jPYFDpLQKXi9p6YFfDv/OKaHe4BZTj5u2yR2jQ3nQCPAXaENKL0v0+wU7/b4F +# XrYPHHfsQ7ayv4N7RBCqW6lzSuBYeF0B21sodIvtb6QEaXBIMNl8yD5nZLzuMz8h +# NFhZn5bviVVspKRU1zuSfMNjCfdqXNtXD/fN27EQ/1YTnjzHlKYV1xOf5ZvztuCa +# /cG/Gpdl8dxR1btdkn16sUP0OJ8p9ABU2FMKkN7m9Y3IfcFCde5I1sBv85vXgF33 +# dt4OCm8nwbPPXNQzWpyyMP1b3gw3WqP/UQ1feH2jw3kQpJSjrC52twWL1bVz9Sq1 +# woiTJDdDaO7LVxbOekuZ/2RCjmRQEOEyauyCKHeI10lfpnovLwl5j/YrWPo0C1Uq +# HDncgnPtNj43HHdTzAZSYnudLJaSsIT8SzyqfaGCDyowgg8mBgorBgEEAYI3AwMB +# MYIPFjCCDxIGCSqGSIb3DQEHAqCCDwMwgg7/AgEDMQ0wCwYJYIZIAWUDBAIBMIGJ +# BgsqhkiG9w0BCRABBKB6BHgwdgIBAQYMKwYBBAGCqTABAwYBMDEwDQYJYIZIAWUD +# BAIBBQAEILwjUxp5SSp5SNWtOEeAl9bQOze4GZ67oILFqeWkm/SiAgguy1sslqCt +# 9RgPMjAyNjEwMTMxMTQyNTVaMAMCAQECEDG0opddQxH8g7xNnzQsiiKgggwAMIIE +# /DCCAuSgAwIBAgIQH2sWYtIuG2xd8cDBoGAOODANBgkqhkiG9w0BAQsFADBzMQsw +# CQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24xETAP +# BgNVBAoMCFNTTCBDb3JwMS8wLQYDVQQDDCZTU0wuY29tIFRpbWVzdGFtcGluZyBJ +# c3N1aW5nIFJTQSBDQSBSMTAeFw0yNTAyMTgxNjMyMDJaFw0zNDExMTIxODUwMDVa +# MG4xCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3Rv +# bjERMA8GA1UECgwIU1NMIENvcnAxKjAoBgNVBAMMIVNTTC5jb20gVGltZXN0YW1w +# aW5nIFVuaXQgMjAyNSBFMTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBv7UVHH +# b+ZVluxPXlfE3M6tg0Xnq8dic+O3vPOCRpalUM1vO9A+GRzSVVjyygHhYrBw62XL +# Fh1kv7e+yRd/aTajggFaMIIBVjAfBgNVHSMEGDAWgBQMnRAljpqnG5mHQ88IfuG9 +# gZD0zzBRBggrBgEFBQcBAQRFMEMwQQYIKwYBBQUHMAKGNWh0dHA6Ly9jZXJ0LnNz +# bC5jb20vU1NMLmNvbS10aW1lU3RhbXBpbmctSS1SU0EtUjEuY2VyMFEGA1UdIARK +# MEgwPAYMKwYBBAGCqTABAwYBMCwwKgYIKwYBBQUHAgEWHmh0dHBzOi8vd3d3LnNz +# bC5jb20vcmVwb3NpdG9yeTAIBgZngQwBBAIwFgYDVR0lAQH/BAwwCgYIKwYBBQUH +# AwgwRgYDVR0fBD8wPTA7oDmgN4Y1aHR0cDovL2NybHMuc3NsLmNvbS9TU0wuY29t +# LXRpbWVTdGFtcGluZy1JLVJTQS1SMS5jcmwwHQYDVR0OBBYEFM582cAEgMUkEGoJ +# 6hyrJT0R/ajSMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAgHN1 +# JIW9ZlNip07FfY9r1LJwLKTl1h/y9r1BjDgHMEz7U7J7mAy32JxA1KLyjCCJqNku +# RfhDHGFEfZtjTOru7AQMDSDiUDvCCc3jaz5py9jGvwIe5npkLbXqXhA+SqCzG719 +# USigwhHkYr7v8NiXoaY0Xu03p0BiZZreZlpngmGB9+86N94FqIFx2I5Z5iuu7d4j +# 7aAOZBNxfU9jFDIIGDtWfWeCpF0Q8rLE/3DHDlzJKs5Il3i/VoOl7rTU938oSrxM +# www6GDBlKX8rGHxvkjgIb9MXkVglJSDHsNAZDeCS/inaxKHA+ChuB8K4OxIxa6k1 +# e+eHBypakAY3wuN7w7PlPmhN6k5IdqZn3HZHs2VjoR841Z9wmVPFdGDdkBZ7XOmb +# 5OZUaKufsvtER0VbY2DkzaqAaItt1kc+I+FUz7PiVz2PUzpegkftRRxvrPyIL08b +# lJTnbMQ0XrqDN0rEfuTLv4XhhnKyJaXhxCUdjO/cRumMzZ35QTUMItBWy1xGp0iy +# oFVnAya2pPsVMuAI+sy1zxlOS9l5iSxvKJ8gpnpOgjqYTa7u1eyZo+4JlcGoiiR1 +# 7LthMTrF1q62tO5xOpI8txUZ6gtKGtkJV6wdj+vZLcwKZ2M93GqVpOo6UBlGjx1s +# KsVZ7AIUzJZli4NJ0M2KYcrfNnlebkYKpDnSqWwwggb8MIIE5KADAgECAhBtUhhw +# h+gjTYVgANCAj5NWMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMQ4wDAYD +# VQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBv +# cmF0aW9uMTEwLwYDVQQDDChTU0wuY29tIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRo +# b3JpdHkgUlNBMB4XDTE5MTExMzE4NTAwNVoXDTM0MTExMjE4NTAwNVowczELMAkG +# A1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYD +# VQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNvbSBUaW1lc3RhbXBpbmcgSXNz +# dWluZyBSU0EgQ0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCu +# URAT0vk8IKAghd7JUBxkyeH9xek0/wp/MUjoclrFXqhh/fGH91Fc+7fm0MHCE7A+ +# wmOiqBj9ODrJAYGq3rm33jCnHSsCBNWAQYyoauLq8IjqsS1JlXL29qDNMMdwZ8UN +# zQS7vWZMDJ40JSGNphMGTIA2qn2bohGtgRc4p1395ESypUOaGvJ3t0FNL3BuKmb6 +# YctMcQUF2sqooMzd89h0E6ujdvBDo6ZwNnWoxj7YmfWjSXg33A5GuY9ym4QZM5OE +# Vgo8ebz/B+gyhyCLNNhh4Mb/4xvCTCMVmNYrBviGgdPZYrym8Zb84TQCmSuX0JlL +# La6WK1aO6qlwISbb9bVGh866ekKblC/XRP20gAu1CjvcYciUgNTrGFg8f8AJgQPO +# Cc1/CCdaJSYwhJpSdheKOnQgESgNmYZPhFOC6IKaMAUXk5U1tjTcFCgFvvArXtK4 +# azAWUOO1Y3fdldIBL6LjkzLUCYJNkFXqhsBVcPMuB0nUDWvLJfPimstjJ8lF4S6E +# CxWnlWi7OElVwTnt1GtRqeY9ydvvGLntU+FecK7DbqHDUd366UreMkSBtzevAc9a +# qoZPnjVMjvFqV1pYOjzmTiVHZtAc80bAfFe5LLfJzPI6DntNyqobpwTevQpHqPDN +# 9qqNO83r3kaw8A9j+HZiSw2AX5cGdQP0kG0vhzfgBwIDAQABo4IBgTCCAX0wEgYD +# VR0TAQH/BAgwBgEB/wIBADAfBgNVHSMEGDAWgBTdBAkHovV6fVJTEpKV7jiAJQ2m +# WTCBgwYIKwYBBQUHAQEEdzB1MFEGCCsGAQUFBzAChkVodHRwOi8vd3d3LnNzbC5j +# b20vcmVwb3NpdG9yeS9TU0xjb21Sb290Q2VydGlmaWNhdGlvbkF1dGhvcml0eVJT +# QS5jcnQwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3Nwcy5zc2wuY29tMD8GA1UdIAQ4 +# MDYwNAYEVR0gADAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3Jl +# cG9zaXRvcnkwEwYDVR0lBAwwCgYIKwYBBQUHAwgwOwYDVR0fBDQwMjAwoC6gLIYq +# aHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0GA1Ud +# DgQWBBQMnRAljpqnG5mHQ88IfuG9gZD0zzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +# hvcNAQELBQADggIBAJIZdQ2mWkLPGQfZ8vyU+sCb8BXpRJZaL3Ez3VDlE3uZk3cP +# xPtybVfLuqaci0W6SB22JTMttCiQMnIVOsXWnIuAbD/aFTcUkTLBI3xys+wEajzX +# aXJYWACDS47BRjDtYlDW14gLJxf8W6DQoH3jHDGGy8kGJFOlDKG7/YrK7UGfHtBA +# EDVe6lyZ+FtCsrk7dD/IiL/+Q3Q6SFASJLQ2XI89ihFugdYL77CiDNXrI2MFspQG +# swXEAGpHuaQDTHUp/LdR3TyrIsLlnzoLskUGswF/KF8+kpWUiKJNC4rPWtNrxlbX +# YRGgdEdx8SMjUTDClldcrknlFxbqHsVmr9xkT2QtFmG+dEq1v5fsIK0vHaHrWjMM +# maJ9i+4qGJSD0stYfQ6v0PddT7EpGxGd867Ada6FZyHwbuQSadMb0K0P0OC2r7rw +# qBUe0BaMqTa6LWzWItgBjGcObXeMxmbQqlEz2YtAcErkZvh0WABDDE4U8GyV/32F +# daAvJgTfe9MiL2nSBioYe/g5mHUSWAay/Ip1RQmQCvmF9sNfqlhJwkjy/1U1ibUk +# TIUBX3HgymyQvqQTZLLys6pL2tCdWcjI9YuLw30rgZm8+K387L7ycUvqrmQ3ZJlu +# jHl3r1hgV76s3WwMPgKk1bAEFMj+rRXimSC+Ev30hXZdqyMdl/il5Ksd0vhGMYIC +# WTCCAlUCAQEwgYcwczELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD +# VQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNv +# bSBUaW1lc3RhbXBpbmcgSXNzdWluZyBSU0EgQ0EgUjECEB9rFmLSLhtsXfHAwaBg +# DjgwCwYJYIZIAWUDBAIBoIIBYTAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQw +# HAYJKoZIhvcNAQkFMQ8XDTI2MTAxMzExNDI1NVowKAYJKoZIhvcNAQk0MRswGTAL +# BglghkgBZQMEAgGhCgYIKoZIzj0EAwIwLwYJKoZIhvcNAQkEMSIEIK/8cL5Ggmw4 +# 6/0pPZ3foOUG6qBosHe/oEe2ikm+rfsJMIHJBgsqhkiG9w0BCRACLzGBuTCBtjCB +# szCBsAQgVCr5oWqNci5mEUl4iumUwYqaruWmXLNEolSa+Wx5x4swgYswd6R1MHMx +# CzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjER +# MA8GA1UECgwIU1NMIENvcnAxLzAtBgNVBAMMJlNTTC5jb20gVGltZXN0YW1waW5n +# IElzc3VpbmcgUlNBIENBIFIxAhAfaxZi0i4bbF3xwMGgYA44MAoGCCqGSM49BAMC +# BEgwRgIhAK/KrflSb5ZBSIuxBNhVr/AUwptddNLyLCFP1dnpvZTeAiEApPj5t6Ob +# usFfcx2rl6o9Ks21SidX9IVWyE++iMg1BHA= +# SIG # End signature block diff --git a/package/PurviewDlpReport.psm1 b/package/PurviewDlpReport.psm1 index 9697570..36aac26 100644 --- a/package/PurviewDlpReport.psm1 +++ b/package/PurviewDlpReport.psm1 @@ -1,1121 +1,1427 @@ -#Requires -Version 7.4 -<# -.SYNOPSIS - Purview DLP Report - PowerShell module. - -.DESCRIPTION - Helper functions used by Invoke-PurviewDlpReport.ps1. The module is organised - in regions, in the order of an execution: - - 1. Console and log Write-Dlp* functions (what the administrator sees) - 2. Configuration Import-DlpConfiguration (reads and checks the .psd1 file) - 3. Engine Initialize-DlpEngine (SQLite + compiled C# engine) - 4. Periods and coverage Resolve-DlpPeriod, Get-DlpCollectionPlan, Get-DlpCoverage - 5. Connection Connect-DlpActivityExplorer / Disconnect-DlpActivityExplorer - 6. Collection Invoke-DlpCollection (Activity Explorer -> SQLite) - 7. Report New-DlpReport (SQLite -> CSV / HTML) - 8. Status and maintenance Show-DlpStatus, Invoke-DlpRetention, Enter-DlpLock - - Performance-critical work (JSON parsing, database, file writing) is done by - src\PurviewDlpReport.Engine.cs, compiled on first use. - -.NOTES - Author : Nicolas Fabert - Version : 2.1.1 - History : see CHANGELOG.md -#> -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$script:ToolVersion = '2.1.1' -$script:ToolRoot = $PSScriptRoot -# Prefix given to the Security & Compliance cmdlets imported by Connect-IPPSSession. -# It avoids any clash with another Exchange/Purview session opened in the same console. -$script:CommandPrefix = 'PdrAe' -$script:LogWriter = $null -$script:LogPath = $null -# --------------------------------------------------------------------------------------------- -# Console theme: colours and icons. -# - Colours (ANSI) are disabled when the output is redirected (scheduled task, log capture) -# or when the NO_COLOR environment variable is set; PDR_FORCE_COLOR=1 forces them. -# - Icons: emoji in modern terminals (Windows Terminal, VS Code), simple symbols elsewhere. -# Emoji are chosen among those always two columns wide (no variation selector), so frames -# and columns stay aligned. The classic console (conhost) has no font fallback: a character -# missing from its font is shown as an empty box. The symbols used outside the modern -# terminals are therefore all present in Consolas and Lucida Console (code page 437 -# repertoire or Latin-1) - a test checks it. Force a style with the environment -# variable PDR_ICONS = Emoji | Symbols | Ascii. -# - Frames: rounded corners, present in Consolas (default font of the console). Lucida Console -# and the raster font have no rounded corners: square corners are used with them (the -# console font is read when the frame is drawn, see Get-DlpFrame). -# --------------------------------------------------------------------------------------------- -$script:C = @{ Reset = ''; Bold = ''; Dim = ''; Accent = ''; AccentBg = ''; Cyan = ''; Green = ''; Yellow = ''; Red = ''; White = '' } -if ($env:PDR_FORCE_COLOR -eq '1' -or (-not [Console]::IsOutputRedirected -and -not $env:NO_COLOR)) { - $e = [char]27 - $script:C = @{ - Reset = "$e[0m"; Bold = "$e[1m"; Dim = "$e[90m"; White = "$e[97m" - Accent = "$e[38;2;214;62;115m"; AccentBg = "$e[48;2;177;31;75m$e[97m" - Cyan = "$e[38;2;97;214;214m"; Green = "$e[38;2;80;200;120m"; Yellow = "$e[38;2;240;200;90m"; Red = "$e[38;2;240;90;90m" - } -} -$script:IconStyle = if ($env:PDR_ICONS -in 'Emoji', 'Symbols', 'Ascii') { $env:PDR_ICONS } - elseif ([Console]::IsOutputRedirected) { 'Symbols' } - elseif ($env:WT_SESSION -or $env:TERM_PROGRAM -eq 'vscode') { 'Emoji' } - else { 'Symbols' } - -function Get-DlpIconSet { - <# Icons of one console style. Symbols: only characters of the classic console fonts. #> - param([Parameter(Mandatory)][ValidateSet('Emoji', 'Symbols', 'Ascii')][string]$Style) - $u = { param([int]$Code) [char]::ConvertFromUtf32($Code) } - switch ($Style) { - 'Emoji' { return @{ - Logo = & $u 0x1F512; Ok = & $u 0x2705; Warn = (& $u 0x26A0) + [char]0xFE0F - Fail = & $u 0x274C; Info = & $u 0x1F539; Skip = & $u 0x23E9 - Database = & $u 0x1F4BE; Plan = & $u 0x1F50E; Key = & $u 0x1F510 - Download = & $u 0x1F4E5; Report = & $u 0x1F4CA; Calendar = & $u 0x1F4C5 - File = & $u 0x1F4C4; Folder = & $u 0x1F4C1; Clock = & $u 0x23F3 - Mail = & $u 0x1F4E8; Target = & $u 0x1F3AF; Log = & $u 0x1F4DD - Done = & $u 0x1F389; People = & $u 0x1F465; Chart = & $u 0x1F4C8 - } } - 'Symbols' { return @{ - Logo = & $u 0x2666; Ok = & $u 0x221A; Warn = & $u 0x25B2; Fail = & $u 0x00D7; Info = & $u 0x2022 - Skip = & $u 0x00BB; Database = & $u 0x25A0; Plan = & $u 0x25BA; Key = & $u 0x2194; Download = & $u 0x2193 - Report = & $u 0x2261; Calendar = & $u 0x263C; File = & $u 0x25AC; Folder = & $u 0x2302; Clock = & $u 0x25CB - Mail = '@'; Target = & $u 0x25D9; Log = & $u 0x00B6; Done = & $u 0x221A; People = & $u 0x2192; Chart = & $u 0x2191 - } } - default { return @{ - Logo = '*'; Ok = '+'; Warn = '!'; Fail = 'x'; Info = '-'; Skip = '>'; Database = '#'; Plan = '?'; Key = '@'; Download = 'v'; Report = '='; Calendar = ':' - File = '-'; Folder = '>'; Clock = '~'; Mail = '@'; Target = 'o'; Log = '='; Done = '*'; People = '&'; Chart = '^' - } } - } -} - -function Get-DlpFrameSet { - <# - .SYNOPSIS - Frame characters. Rounded corners, except with the Ascii style and with the console fonts - that have no rounded corners (Lucida Console, raster font 'Terminal'): square corners. - #> - param([Parameter(Mandatory)][ValidateSet('Emoji', 'Symbols', 'Ascii')][string]$Style, [AllowNull()][string]$FontName) - if ($Style -eq 'Ascii') { return @{ TopLeft = [char]'+'; TopRight = [char]'+'; BottomLeft = [char]'+'; BottomRight = [char]'+'; Horizontal = [char]'-'; Vertical = [char]'|' } } - if ($Style -eq 'Symbols' -and $FontName -in 'Lucida Console', 'Terminal') { - return @{ TopLeft = [char]0x250C; TopRight = [char]0x2510; BottomLeft = [char]0x2514; BottomRight = [char]0x2518; Horizontal = [char]0x2500; Vertical = [char]0x2502 } - } - return @{ TopLeft = [char]0x256D; TopRight = [char]0x256E; BottomLeft = [char]0x2570; BottomRight = [char]0x256F; Horizontal = [char]0x2500; Vertical = [char]0x2502 } -} - -function Get-DlpFrame { - <# Frame characters for this console. In the classic console the font is read once, through the engine. #> - if ($script:Frame) { return $script:Frame } - $engine = [bool]('PurviewDlpReport.ConsoleFont' -as [type]) - $font = if ($script:IconStyle -eq 'Symbols' -and $engine) { [PurviewDlpReport.ConsoleFont]::FaceName() } - $frame = Get-DlpFrameSet $script:IconStyle $font - # Before the engine is loaded (error at start) the font is unknown: not kept, read again later. - if ($script:IconStyle -ne 'Symbols' -or $engine) { $script:Frame = $frame } - return $frame -} - -$script:Icons = Get-DlpIconSet $script:IconStyle -$script:Frame = $null -# Emoji are two columns wide in the console; symbols are one: pad symbols so text stays aligned. -$script:IconPad = if ($script:IconStyle -eq 'Emoji') { ' ' } else { ' ' } - -#region 1. Console and log --------------------------------------------------------------- - -function Get-DlpIcon { - param([Parameter(Mandatory)][string]$Name) - return $script:Icons[$Name] + $script:IconPad -} - -function Format-DlpNumber { - param([Parameter(Mandatory)][AllowNull()]$Value) - if ($null -eq $Value) { return '-' } - return ([long]$Value).ToString('N0', [Globalization.CultureInfo]::GetCultureInfo('en-US')) -} - -function Format-DlpDuration { - param([Parameter(Mandatory)][double]$Seconds) - # 0.0 (not 0): with an integer first argument PowerShell picks Math.Max(int, int) and drops the decimals. - $t = [TimeSpan]::FromTicks([long]([Math]::Max(0.0, $Seconds) * 10000000)) - if ($t.TotalDays -ge 2) { return '{0} d {1:00} h' -f [int][Math]::Floor($t.TotalDays), $t.Hours } - if ($t.TotalHours -ge 1) { return '{0} h {1:00} min' -f [int][Math]::Floor($t.TotalHours), $t.Minutes } - if ($t.TotalMinutes -ge 1) { return '{0} min {1:00} s' -f $t.Minutes, $t.Seconds } - return '{0:0.0} s' -f $t.TotalSeconds -} - -function Format-DlpBytes { - param([Parameter(Mandatory)][double]$Bytes) - if ($Bytes -ge 1GB) { return '{0:0.00} GB' -f ($Bytes / 1GB) } - if ($Bytes -ge 1MB) { return '{0:0.0} MB' -f ($Bytes / 1MB) } - if ($Bytes -ge 1KB) { return '{0:0} KB' -f ($Bytes / 1KB) } - return '{0:0} B' -f $Bytes -} - -function Format-DlpLocalTime { - param([Parameter(Mandatory)][long]$UnixMs, [Parameter(Mandatory)][TimeZoneInfo]$Zone, [string]$Format = 'yyyy-MM-dd HH:mm') - return [TimeZoneInfo]::ConvertTime([DateTimeOffset]::FromUnixTimeMilliseconds($UnixMs), $Zone).ToString($Format, [Globalization.CultureInfo]::InvariantCulture) -} - -function Format-DlpRange { - param([long]$StartMs, [long]$EndMs, [TimeZoneInfo]$Zone) - return '{0} {2} {1}' -f (Format-DlpLocalTime $StartMs $Zone), (Format-DlpLocalTime $EndMs $Zone), [char]0x2192 -} - -function Start-DlpLog { - <# Opens (or continues) today's log file and deletes log files older than the retention. #> - param([Parameter(Mandatory)][string]$Directory, [int]$RetentionDays = 30) - [void][IO.Directory]::CreateDirectory($Directory) - $script:LogPath = Join-Path $Directory ('PurviewDlpReport_{0:yyyyMMdd}.log' -f (Get-Date)) - $stream = [IO.FileStream]::new($script:LogPath, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::ReadWrite) - $script:LogWriter = [IO.StreamWriter]::new($stream, [Text.UTF8Encoding]::new($false)) - $script:LogWriter.AutoFlush = $true - $limit = (Get-Date).AddDays(-$RetentionDays) - Get-ChildItem -LiteralPath $Directory -Filter 'PurviewDlpReport_*.log' -File -ErrorAction SilentlyContinue | - Where-Object LastWriteTime -lt $limit | Remove-Item -Force -ErrorAction SilentlyContinue - return $script:LogPath -} - -function Stop-DlpLog { - if ($script:LogWriter) { $script:LogWriter.Dispose(); $script:LogWriter = $null } -} - -function Write-DlpLog { - <# Writes one line to the log file only (never to the console). The log never contains colours or icons. #> - param([ValidateSet('INFO', 'OK', 'WARN', 'ERROR', 'STEP', 'DEBUG')][string]$Level = 'INFO', [Parameter(Mandatory)][AllowEmptyString()][string]$Message) - if ($script:LogWriter) { - $script:LogWriter.WriteLine(('{0:yyyy-MM-ddTHH:mm:ss.fffzzz} [{1,-5}] {2}' -f (Get-Date), $Level, $Message)) - } -} - -function Write-DlpBanner { - <# - .SYNOPSIS - Title card at the start of an execution: - - ╭──────────────────────────────────────────────────────────────────────╮ - │ 🛡 Purview DLP Report v2.0.0 · Nicolas Fabert │ - │ Exchange Online DLP matches · one row per Message ID │ - ╰──────────────────────────────────────────────────────────────────────╯ - 📅 Period 2026-09-22 19:58 → 2026-09-29 19:58 ... - .PARAMETER Details - Ordered list of rows: key = label, value = @(IconName, Text) or plain text. - #> - param([Parameter(Mandatory)][string]$Title, [string]$Subtitle, [System.Collections.Specialized.OrderedDictionary]$Details) - $C = $script:C; $F = Get-DlpFrame; $width = 74 - $right = "v$($script:ToolVersion) $([char]0x00B7) Nicolas Fabert" - $iconWidth = if ($script:IconStyle -eq 'Emoji') { 2 } else { 1 } - $left = " $($script:Icons.Logo) $Title" - $gap = [Math]::Max(1, $width - ($left.Length - $script:Icons.Logo.Length + $iconWidth) - $right.Length - 2) - Write-Host '' - Write-Host (" {0}{1}{2}{3}{4}" -f $C.Accent, $F.TopLeft, [string]::new($F.Horizontal, $width), $F.TopRight, $C.Reset) - Write-Host (" {0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}{11}" -f $C.Accent, $F.Vertical, $C.Reset, $C.Bold, $left, $C.Reset, [string]::new(' ', $gap), $C.Dim, $right, ' ', ($C.Accent + $F.Vertical), $C.Reset) - if ($Subtitle) { - $sub = " $Subtitle" - Write-Host (" {0}{1}{2}{3}{4}{5}{0}{6}{2}" -f $C.Accent, $F.Vertical, $C.Reset, $C.Dim, $sub.PadRight($width), $C.Reset, $F.Vertical) - } - Write-Host (" {0}{1}{2}{3}{4}" -f $C.Accent, $F.BottomLeft, [string]::new($F.Horizontal, $width), $F.BottomRight, $C.Reset) - if ($Details) { - foreach ($key in $Details.Keys) { - $value = $Details[$key] - $icon, $text = if ($value -is [array]) { (Get-DlpIcon $value[0]), $value[1] } else { ' ', $value } - Write-Host (" {0}{1}{2,-10}{3} {4}" -f $icon, $C.Dim, $key, $C.Reset, $text) - } - } - Write-DlpLog 'STEP' "=== $Title v$($script:ToolVersion) ===" - if ($Details) { foreach ($key in $Details.Keys) { $v = $Details[$key]; Write-DlpLog 'INFO' ("{0}: {1}" -f $key, $(if ($v -is [array]) { $v[1] } else { $v })) } } -} - -function Write-DlpStep { - <# - .SYNOPSIS - Step header with a coloured number pill and an icon, e.g. - - ─── 3/5 ─ 🔐 Connecting to Security & Compliance PowerShell - #> - param([Parameter(Mandatory)][int]$Number, [Parameter(Mandatory)][int]$Total, [Parameter(Mandatory)][string]$Title, [string]$Icon = 'Info') - $C = $script:C - Write-Host '' - Write-Host (" {0} {1}/{2} {3} {4}{5}{6}{3}" -f $C.AccentBg, $Number, $Total, $C.Reset, (Get-DlpIcon $Icon), $C.Bold, $Title) - Write-DlpLog 'STEP' "[$Number/$Total] $Title" -} - -function Write-DlpItem { - <# One indented result line with a status icon, also written to the log. #> - param([ValidateSet('Ok', 'Warn', 'Fail', 'Info', 'Skip')][string]$Status = 'Info', [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Icon) - $color = @{ Ok = $script:C.Green; Warn = $script:C.Yellow; Fail = $script:C.Red; Info = ''; Skip = $script:C.Dim }[$Status] - $level = @{ Ok = 'OK'; Warn = 'WARN'; Fail = 'ERROR'; Info = 'INFO'; Skip = 'INFO' }[$Status] - $symbol = Get-DlpIcon $(if ($Icon) { $Icon } else { $Status }) - $textColor = if ($Status -in 'Warn', 'Fail', 'Skip') { $color } else { '' } - Write-Host (" {0}{1}{2}{3}{4}{2}" -f $color, $symbol, $script:C.Reset, $textColor, $Text) - Write-DlpLog $level $Text -} - -function Write-DlpTableRow { - <# - .SYNOPSIS - One aligned row of the collection table (one row per Activity Explorer window). - Columns: status icon, window, events, new, pages, duration, rate. - #> - param([switch]$Header, [ValidateSet('Ok', 'Warn', 'Fail')][string]$Status = 'Ok', [string]$Window, $Events, $New, $Pages, [string]$Duration, [string]$Rate) - $C = $script:C - if ($Header) { - Write-Host (" {0}{1}{2,-35} {3,10} {4,10} {5,6} {6,12} {7,9}{8}" -f $C.Dim, (' ' + $script:IconPad), 'Window', 'Events', 'New', 'Pages', 'Duration', 'Rate', $C.Reset) - return - } - $color = @{ Ok = $C.Green; Warn = $C.Yellow; Fail = $C.Red }[$Status] - $eventsText = Format-DlpNumber $Events - $eventsColor = if ([long]$Events -gt 0) { $C.White + $C.Bold } else { $C.Dim } - Write-Host (" {0}{1}{2}{3,-35} {4}{5,10}{2} {6,10} {7,6} {8,12} {9}{10,9}{2}" -f $color, (Get-DlpIcon $Status), $C.Reset, $Window, $eventsColor, $eventsText, (Format-DlpNumber $New), $Pages, $Duration, $C.Dim, $Rate) - Write-DlpLog $(if ($Status -eq 'Ok') { 'OK' } else { 'WARN' }) ("{0} {1} events, {2} new, {3} pages, {4}, {5}" -f $Window, $eventsText, (Format-DlpNumber $New), $Pages, $Duration, $Rate) -} - -function Write-DlpSummary { - <# - .SYNOPSIS - Final summary card: - - ╭─ 🎉 Report ready ─────────────────────────────────────────────────╮ - 📅 Period ... - ✉️ Messages ... - ╰────────────────────────────────────────────────────────────────────╯ - .PARAMETER Values - Ordered list: key = label, value = @(IconName, Text) or plain text. - #> - param([Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][System.Collections.Specialized.OrderedDictionary]$Values, [ValidateSet('Ok', 'Warn', 'Fail')][string]$Status = 'Ok') - $C = $script:C; $F = Get-DlpFrame; $width = 74 - $color = @{ Ok = $C.Green; Warn = $C.Yellow; Fail = $C.Red }[$Status] - $icon = $script:Icons[@{ Ok = 'Done'; Warn = 'Warn'; Fail = 'Fail' }[$Status]] - $iconWidth = if ($script:IconStyle -eq 'Emoji') { 2 } else { 1 } - $head = " $icon $Title " - $rest = [Math]::Max(2, $width - 1 - ($head.Length - $icon.Length + $iconWidth)) - Write-Host '' - Write-Host (" {0}{1}{2}{3}{4}{0}{5}{6}{7}" -f $color, $F.TopLeft, $F.Horizontal, $C.Bold, $head, ($C.Reset + $color), ([string]::new($F.Horizontal, $rest) + $F.TopRight), $C.Reset) - foreach ($key in $Values.Keys) { - $value = $Values[$key] - $rowIcon, $text = if ($value -is [array]) { (Get-DlpIcon $value[0]), $value[1] } else { ' ', $value } - Write-Host (" {0}{1}{2,-10}{3} {4}" -f $rowIcon, $C.Dim, $key, $C.Reset, $text) - Write-DlpLog 'INFO' ("Summary - {0}: {1}" -f $key, $text) - } - Write-Host (" {0}{1}{2}{3}{4}" -f $color, $F.BottomLeft, [string]::new($F.Horizontal, $width), $F.BottomRight, $C.Reset) - Write-Host '' -} - -#endregion -#region 2. Configuration ------------------------------------------------------------------ - -function Resolve-DlpPath { - param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Root) - $expanded = [Environment]::ExpandEnvironmentVariables($Path) - if ([IO.Path]::IsPathRooted($expanded)) { return [IO.Path]::GetFullPath($expanded) } - return [IO.Path]::GetFullPath((Join-Path $Root $expanded)) -} - -function Import-DlpConfiguration { - <# - .SYNOPSIS - Reads the configuration file, checks every value and returns it with absolute paths. - All problems are reported together so the administrator can fix them in one go. - #> - [CmdletBinding()] - param([Parameter(Mandatory)][string]$Path, [string]$Root = $script:ToolRoot) - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Configuration file not found: $Path" } - try { $config = Import-PowerShellDataFile -LiteralPath $Path } - catch { throw "The configuration file is not valid PowerShell data ($Path): $($_.Exception.Message)" } - - $errors = [Collections.Generic.List[string]]::new() - $guid = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' - foreach ($section in 'Tenant', 'Target', 'Authentication', 'Collection', 'Storage', 'Report', 'Logging') { - if (-not $config.ContainsKey($section) -or $config[$section] -isnot [hashtable]) { $errors.Add("Section '$section' is missing.") } - } - if ($errors.Count) { throw ("Invalid configuration ($Path):`n - " + ($errors -join "`n - ")) } - - function Get-Value([hashtable]$Section, [string]$SectionName, [string]$Key, $Default, [switch]$Required) { - if ($Section.ContainsKey($Key) -and $null -ne $Section[$Key] -and "$($Section[$Key])" -ne '') { return $Section[$Key] } - if ($Required) { $errors.Add("$SectionName.$Key is required.") } - return $Default - } - function Test-Int($Value, [string]$Name, [long]$Min, [long]$Max) { - $n = 0L - if (-not [long]::TryParse("$Value", [ref]$n) -or $n -lt $Min -or $n -gt $Max) { $errors.Add("$Name must be a whole number between $Min and $Max (current value: '$Value').") } - return [int]$n - } - function Test-Bool($Value, [string]$Name) { - if ($Value -isnot [bool]) { $errors.Add("$Name must be `$true or `$false (current value: '$Value').") ; return $false } - return $Value - } - - $t = $config.Tenant; $g = $config.Target; $a = $config.Authentication; $c = $config.Collection; $s = $config.Storage; $r = $config.Report; $l = $config.Logging - $settings = [ordered]@{ - ConfigPath = [IO.Path]::GetFullPath($Path) - Tenant = [ordered]@{ - TenantId = [string](Get-Value $t 'Tenant' 'TenantId' '' -Required) - Organization = [string](Get-Value $t 'Tenant' 'Organization' '') - } - Target = [ordered]@{ - PolicyId = [string](Get-Value $g 'Target' 'PolicyId' '' -Required) - RuleId = [string](Get-Value $g 'Target' 'RuleId' '' -Required) - PolicyName = [string](Get-Value $g 'Target' 'PolicyName' '') - RuleName = [string](Get-Value $g 'Target' 'RuleName' '') - } - Authentication = [ordered]@{ - Mode = [string](Get-Value $a 'Authentication' 'Mode' 'Interactive') - UserPrincipalName = [string](Get-Value $a 'Authentication' 'UserPrincipalName' '') - DisableWAM = Get-Value $a 'Authentication' 'DisableWAM' $true - AppId = [string](Get-Value $a 'Authentication' 'AppId' '') - CertificateThumbprint = [string](Get-Value $a 'Authentication' 'CertificateThumbprint' '') - } - Collection = [ordered]@{ - PageSize = Test-Int (Get-Value $c 'Collection' 'PageSize' 1000) 'Collection.PageSize' 1 5000 - SliceHours = Test-Int (Get-Value $c 'Collection' 'SliceHours' 24) 'Collection.SliceHours' 1 24 - SettlingHours = Test-Int (Get-Value $c 'Collection' 'SettlingHours' 6) 'Collection.SettlingHours' 0 72 - BackfillDays = Test-Int (Get-Value $c 'Collection' 'BackfillDays' 30) 'Collection.BackfillDays' 1 30 - SourceRetentionDays = Test-Int (Get-Value $c 'Collection' 'SourceRetentionDays' 30) 'Collection.SourceRetentionDays' 1 365 - RetentionWarningDays = Test-Int (Get-Value $c 'Collection' 'RetentionWarningDays' 7) 'Collection.RetentionWarningDays' 0 364 - MaxSliceRestarts = Test-Int (Get-Value $c 'Collection' 'MaxSliceRestarts' 2) 'Collection.MaxSliceRestarts' 0 5 - MinimumSliceMinutes = Test-Int (Get-Value $c 'Collection' 'MinimumSliceMinutes' 15) 'Collection.MinimumSliceMinutes' 1 720 - MaxPagesPerSlice = Test-Int (Get-Value $c 'Collection' 'MaxPagesPerSlice' 2000) 'Collection.MaxPagesPerSlice' 1 100000 - CookieSafetySeconds = Test-Int (Get-Value $c 'Collection' 'CookieSafetySeconds' 105) 'Collection.CookieSafetySeconds' 30 119 - } - Storage = [ordered]@{ - DatabasePath = Resolve-DlpPath ([string](Get-Value $s 'Storage' 'DatabasePath' '.\data\PurviewDlpReport.sqlite')) $Root - RetentionDays = Test-Int (Get-Value $s 'Storage' 'RetentionDays' 180) 'Storage.RetentionDays' 0 3650 - KeepQuarantinedRecords = Test-Bool (Get-Value $s 'Storage' 'KeepQuarantinedRecords' $true) 'Storage.KeepQuarantinedRecords' - } - Report = [ordered]@{ - DefaultRange = [string](Get-Value $r 'Report' 'DefaultRange' 'Last7Days') - TimeZone = [string](Get-Value $r 'Report' 'TimeZone' 'Europe/Paris') - OutputPath = Resolve-DlpPath ([string](Get-Value $r 'Report' 'OutputPath' '.\reports')) $Root - FilePrefix = [string](Get-Value $r 'Report' 'FilePrefix' 'PurviewDLP') - Formats = @(Get-Value $r 'Report' 'Formats' @('Csv', 'Html')) - IncludeRecipientDetails = Test-Bool (Get-Value $r 'Report' 'IncludeRecipientDetails' $true) 'Report.IncludeRecipientDetails' - SplitBy = [string](Get-Value $r 'Report' 'SplitBy' 'Week') - MaxRowsPerFile = Test-Int (Get-Value $r 'Report' 'MaxRowsPerFile' 500000) 'Report.MaxRowsPerFile' 1000 1048575 - CsvDelimiter = [string](Get-Value $r 'Report' 'CsvDelimiter' ';') - Title = [string](Get-Value $r 'Report' 'Title' 'DLP messages with more than 25 recipients') - TemplatePath = Join-Path $Root 'templates\Report.template.html' - } - Logging = [ordered]@{ - Path = Resolve-DlpPath ([string](Get-Value $l 'Logging' 'Path' '.\logs')) $Root - RetentionDays = Test-Int (Get-Value $l 'Logging' 'RetentionDays' 30) 'Logging.RetentionDays' 1 3650 - } - } - if ($settings.Tenant.TenantId -and $settings.Tenant.TenantId -notmatch $guid) { $errors.Add('Tenant.TenantId must be a GUID.') } - if ($settings.Target.PolicyId -and $settings.Target.PolicyId -notmatch $guid) { $errors.Add('Target.PolicyId must be a GUID.') } - if ($settings.Target.RuleId -and $settings.Target.RuleId -notmatch $guid) { $errors.Add('Target.RuleId must be a GUID.') } - if ($settings.Authentication.Mode -notin 'Interactive', 'Certificate') { $errors.Add("Authentication.Mode must be 'Interactive' or 'Certificate'.") } - [void](Test-Bool $settings.Authentication.DisableWAM 'Authentication.DisableWAM') - if ($settings.Authentication.Mode -eq 'Certificate') { - if ($settings.Authentication.AppId -notmatch $guid) { $errors.Add('Authentication.AppId must be the application (client) ID (GUID) in Certificate mode.') } - if ($settings.Authentication.CertificateThumbprint -notmatch '^[0-9a-fA-F]{40}$') { $errors.Add('Authentication.CertificateThumbprint must be a 40-character thumbprint in Certificate mode.') } - if ($settings.Tenant.Organization -notmatch '\.onmicrosoft\.com$') { $errors.Add('Tenant.Organization must be the initial domain (xxx.onmicrosoft.com) in Certificate mode.') } - } - if ($settings.Collection.RetentionWarningDays -ge $settings.Collection.SourceRetentionDays) { $errors.Add('Collection.RetentionWarningDays must be lower than Collection.SourceRetentionDays.') } - if ($settings.Report.DefaultRange -notin 'Last24Hours', 'Last7Days', 'Last30Days', 'PreviousMonth') { $errors.Add('Report.DefaultRange must be Last24Hours, Last7Days, Last30Days or PreviousMonth.') } - if ($settings.Report.SplitBy -notin 'Rows', 'Day', 'Week') { $errors.Add("Report.SplitBy must be 'Rows', 'Day' or 'Week'.") } - if (-not $settings.Report.Formats.Count -or @($settings.Report.Formats | Where-Object { $_ -notin 'Csv', 'Html' }).Count) { $errors.Add("Report.Formats must contain 'Csv', 'Html' or both.") } - if ($settings.Report.CsvDelimiter -notin ';', ',', "`t", '|') { $errors.Add("Report.CsvDelimiter must be ';', ',', '|' or a tab.") } - if (-not $settings.Report.FilePrefix -or $settings.Report.FilePrefix.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) { $errors.Add('Report.FilePrefix must be a valid file name part.') } - try { $settings['Zone'] = Get-DlpTimeZone $settings.Report.TimeZone } catch { $errors.Add($_.Exception.Message) } - if ($errors.Count) { throw ("Invalid configuration ($Path):`n - " + ($errors -join "`n - ")) } - return $settings -} - -#endregion - -#region 3. Engine (SQLite + compiled C#) ------------------------------------------------------ - -function Initialize-DlpEngine { - <# - .SYNOPSIS - Loads SQLite (lib\sqlite) and the C# engine. The engine is compiled from - src\PurviewDlpReport.Engine.cs into bin\ the first time, and again only when - the source file changes (the file name contains a hash of the source). - #> - [CmdletBinding()] - param([string]$Root = $script:ToolRoot) - if ('PurviewDlpReport.DlpStore' -as [type]) { return } - $lib = Join-Path $Root 'lib\sqlite' - $arch = if ([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq 'Arm64') { 'win-arm64' } else { 'win-x64' } - $native = Join-Path $lib "runtimes\$arch\e_sqlite3.dll" - if (-not (Test-Path -LiteralPath $native)) { throw "SQLite native library not found: $native" } - [void][Runtime.InteropServices.NativeLibrary]::Load($native) - foreach ($name in 'SQLitePCLRaw.core', 'SQLitePCLRaw.provider.e_sqlite3', 'SQLitePCLRaw.batteries_v2', 'Microsoft.Data.Sqlite') { - Add-Type -LiteralPath (Join-Path $lib "$name.dll") - } - [SQLitePCL.Batteries_V2]::Init() - - $source = Join-Path $Root 'src\PurviewDlpReport.Engine.cs' - $hash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash.Substring(0, 16) - $bin = Join-Path $Root 'bin' - $dll = Join-Path $bin "PurviewDlpReport.Engine.$hash.dll" - if (-not (Test-Path -LiteralPath $dll)) { - [void][IO.Directory]::CreateDirectory($bin) - $references = @( - (Join-Path $lib 'Microsoft.Data.Sqlite.dll'), 'System.IO.Compression', 'System.Text.Json', 'System.Text.Encodings.Web', - 'System.Data.Common', 'System.Linq', 'System.Collections', 'System.Text.RegularExpressions', 'System.Security.Cryptography', - 'System.Runtime', 'System.Memory', 'System.ComponentModel.Primitives', 'System.ComponentModel', 'System.Transactions.Local', - 'System.Text.Encoding.Extensions', 'System.Runtime.Extensions', 'System.IO', 'System.Runtime.InteropServices', 'System.Console', 'netstandard') - $staging = Join-Path $bin ("compile-{0}.dll" -f [guid]::NewGuid().ToString('N')) - Add-Type -LiteralPath $source -ReferencedAssemblies $references -OutputAssembly $staging -OutputType Library -IgnoreWarnings -WarningAction SilentlyContinue - Move-Item -LiteralPath $staging -Destination $dll -Force - # Older compiled versions are removed when they are not in use. - Get-ChildItem -LiteralPath $bin -Filter 'PurviewDlpReport.Engine.*.dll' | Where-Object FullName -ne $dll | - Remove-Item -Force -ErrorAction SilentlyContinue - } - if (-not ('PurviewDlpReport.DlpStore' -as [type])) { Add-Type -LiteralPath $dll } -} - -function Open-DlpStore { - param([Parameter(Mandatory)]$Settings, [switch]$ReadOnly) - $path = $Settings.Storage.DatabasePath - if ($ReadOnly -and -not (Test-Path -LiteralPath $path)) { throw "The database does not exist yet: $path. Run a collection first (-Mode Collect)." } - $store = [PurviewDlpReport.DlpStore]::new($path, [bool]$ReadOnly, $script:ToolVersion) - $store.KeepQuarantinedRaw = [bool]$Settings.Storage.KeepQuarantinedRecords - return $store -} - -#endregion - -#region 4. Periods and coverage ------------------------------------------------------------ - -function Get-DlpTimeZone { - <# Accepts an IANA name (Europe/Paris) or a Windows name (Romance Standard Time). #> - param([Parameter(Mandatory)][string]$Id) - try { return [TimeZoneInfo]::FindSystemTimeZoneById($Id) } catch { } - $windowsId = $null - if ([TimeZoneInfo]::TryConvertIanaIdToWindowsId($Id, [ref]$windowsId)) { try { return [TimeZoneInfo]::FindSystemTimeZoneById($windowsId) } catch { } } - $known = @{ 'Europe/Paris' = 'Romance Standard Time'; 'Europe/Brussels' = 'Romance Standard Time'; 'Europe/London' = 'GMT Standard Time'; 'UTC' = 'UTC' } - if ($known.ContainsKey($Id)) { return [TimeZoneInfo]::FindSystemTimeZoneById($known[$Id]) } - throw "Unknown time zone '$Id' (Report.TimeZone). Use a name such as 'Europe/Paris' or 'Romance Standard Time'." -} - -function ConvertTo-DlpUnixMs { - <# Text date -> Unix ms. Without an explicit offset (Z, +02:00) the date is read in the report time zone. #> - param([Parameter(Mandatory)][string]$Text, [Parameter(Mandatory)][TimeZoneInfo]$Zone) - $culture = [Globalization.CultureInfo]::InvariantCulture - if ($Text -match '(Z|[+-]\d{2}:?\d{2})$') { - return [DateTimeOffset]::Parse($Text, $culture).ToUnixTimeMilliseconds() - } - $formats = [string[]]@('yyyy-MM-dd', 'yyyy-MM-dd HH:mm', 'yyyy-MM-ddTHH:mm', 'yyyy-MM-dd HH:mm:ss', 'yyyy-MM-ddTHH:mm:ss') - $local = [datetime]::MinValue - if (-not [datetime]::TryParseExact($Text, $formats, $culture, [Globalization.DateTimeStyles]::None, [ref]$local)) { - throw "Invalid date '$Text'. Use yyyy-MM-dd, 'yyyy-MM-dd HH:mm' or an ISO 8601 value with an offset." - } - return [PurviewDlpReport.Coverage]::LocalToUnixMs($local, $Zone) -} - -function Resolve-DlpPeriod { - <# - .SYNOPSIS - Converts a range name into a [start, end) period in Unix milliseconds. - .DESCRIPTION - Last24Hours / Last7Days / Last30Days : rolling windows ending now. - PreviousMonth : the previous calendar month in the report time zone. - Month (-Month yyyy-MM), Day (-Date yyyy-MM-dd), Custom (-Start / -End). - The end is never later than now. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)][ValidateSet('Last24Hours', 'Last7Days', 'Last30Days', 'PreviousMonth', 'Month', 'Day', 'Custom')][string]$Range, - [string]$Month, [string]$Date, [string]$Start, [string]$End, - [Parameter(Mandatory)][TimeZoneInfo]$Zone, - [DateTimeOffset]$Now = [DateTimeOffset]::UtcNow - ) - $culture = [Globalization.CultureInfo]::InvariantCulture - $nowMs = $Now.ToUnixTimeMilliseconds(); $nowMs -= $nowMs % 1000 - $day = 86400000L - switch ($Range) { - 'Last24Hours' { $s = $nowMs - $day; $e = $nowMs } - 'Last7Days' { $s = $nowMs - 7 * $day; $e = $nowMs } - 'Last30Days' { $s = $nowMs - 30 * $day; $e = $nowMs } - 'PreviousMonth' { - $local = [TimeZoneInfo]::ConvertTime($Now, $Zone).DateTime - $first = [datetime]::new($local.Year, $local.Month, 1).AddMonths(-1) - $s = [PurviewDlpReport.Coverage]::LocalToUnixMs($first, $Zone) - $e = [PurviewDlpReport.Coverage]::LocalToUnixMs($first.AddMonths(1), $Zone) - } - 'Month' { - $first = [datetime]::MinValue - if (-not $Month -or -not [datetime]::TryParseExact($Month, 'yyyy-MM', $culture, 'None', [ref]$first)) { throw "-Range Month requires -Month in the format yyyy-MM (for example -Month 2026-08)." } - $s = [PurviewDlpReport.Coverage]::LocalToUnixMs($first, $Zone) - $e = [PurviewDlpReport.Coverage]::LocalToUnixMs($first.AddMonths(1), $Zone) - } - 'Day' { - $d = [datetime]::MinValue - if (-not $Date -or -not [datetime]::TryParseExact($Date, 'yyyy-MM-dd', $culture, 'None', [ref]$d)) { throw "-Range Day requires -Date in the format yyyy-MM-dd." } - $s = [PurviewDlpReport.Coverage]::LocalToUnixMs($d, $Zone) - $e = [PurviewDlpReport.Coverage]::LocalToUnixMs($d.AddDays(1), $Zone) - } - 'Custom' { - if (-not $Start -or -not $End) { throw '-Range Custom requires -Start and -End.' } - $s = ConvertTo-DlpUnixMs $Start $Zone - $e = ConvertTo-DlpUnixMs $End $Zone - } - } - if ($e -gt $nowMs) { $e = $nowMs } - if ($e -le $s) { throw "The requested period is empty or in the future ($Range)." } - [pscustomobject]@{ - Range = $Range - StartMs = [long]$s - EndMs = [long]$e - Label = [PurviewDlpReport.ReportPlanner]::PeriodLabel($s, $e, $Zone) - } -} - -function Get-DlpRangeLength { - <# Total length in milliseconds of a list of ranges (each with Start and End). #> - param([AllowNull()][AllowEmptyCollection()]$Ranges) - $sum = 0L - foreach ($r in @($Ranges)) { if ($null -ne $r) { $sum += [long]$r.End - [long]$r.Start } } - return $sum -} - -function New-DlpRangeList { - param([Parameter()][AllowEmptyCollection()][object[]]$Ranges) - $list = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() - foreach ($r in $Ranges) { if ($null -ne $r) { $list.Add([PurviewDlpReport.TimeRange]::new([long]$r.Start, [long]$r.End)) } } - return , $list -} - -function Get-DlpCollectionPlan { - <# - .SYNOPSIS - Decides what must be collected for a period. - .DESCRIPTION - - Ranges already collected and "settled" (collected at least SettlingHours after - their end) are skipped. - - Missing ranges younger than the Activity Explorer retention are collected, - cut into slices that never cross local midnight. - - Missing ranges older than the retention are reported as unrecoverable. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, - [Parameter(Mandatory)][long]$StartMs, [Parameter(Mandatory)][long]$EndMs, - [Parameter(Mandatory)]$Settings, [long]$NowMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - ) - $c = $Settings.Collection - $settled = $Store.GetCoveredRanges($TargetId, [long]$c.SettlingHours * 3600000) - $missing = [PurviewDlpReport.Coverage]::Gaps($settled, $StartMs, $EndMs) - # One hour of margin: the oldest hour may already be purged by the time it is requested. - $earliest = $NowMs - [long]$c.SourceRetentionDays * 86400000 + 3600000 - $collectable = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() - $lost = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() - foreach ($gap in $missing) { - if ($gap.End -le $earliest) { $lost.Add($gap); continue } - if ($gap.Start -lt $earliest) { - $lost.Add([PurviewDlpReport.TimeRange]::new($gap.Start, $earliest)) - $collectable.Add([PurviewDlpReport.TimeRange]::new($earliest, $gap.End)) - } else { $collectable.Add($gap) } - } - $slices = [PurviewDlpReport.Coverage]::SplitIntoSlices($collectable, $Settings.Zone, $c.SliceHours) - # Part of the collectable time never collected before (the rest is a refresh of recent data). - $coveredAny = $Store.GetCoveredRanges($TargetId, 0) - $neverCollected = 0L - foreach ($range in $collectable) { $neverCollected += Get-DlpRangeLength ([PurviewDlpReport.Coverage]::Gaps($coveredAny, $range.Start, $range.End)) } - [pscustomobject]@{ - Missing = $missing - Collectable = $collectable - Unrecoverable = $lost - Slices = $slices - CollectableMs = Get-DlpRangeLength $collectable - NewMs = $neverCollected - RefreshMs = (Get-DlpRangeLength $collectable) - $neverCollected - UnrecoverableMs = Get-DlpRangeLength $lost - EarliestSourceMs = $earliest - } -} - -function Get-DlpCoverage { - <# Share of a period present in the database (any completed collection, settled or not). #> - param([Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, [Parameter(Mandatory)][long]$StartMs, [Parameter(Mandatory)][long]$EndMs) - $covered = $Store.GetCoveredRanges($TargetId, 0) - $gaps = [PurviewDlpReport.Coverage]::Gaps($covered, $StartMs, $EndMs) - $missingMs = Get-DlpRangeLength $gaps - [pscustomobject]@{ - Percent = if ($EndMs -gt $StartMs) { [Math]::Round(100.0 * ($EndMs - $StartMs - $missingMs) / ($EndMs - $StartMs), 2) } else { 100 } - Gaps = $gaps - MissingMs = $missingMs - } -} - -function Get-DlpExpiringGaps { - <# Missing ranges that Activity Explorer will no longer return within RetentionWarningDays. #> - param([Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, [Parameter(Mandatory)]$Settings, [long]$NowMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) - $c = $Settings.Collection - $stats = $Store.GetStatistics($TargetId) - if ($null -eq $stats.FirstCoveredMs) { return @() } - $windowStart = [Math]::Max([long]$stats.FirstCoveredMs, $NowMs - [long]$c.SourceRetentionDays * 86400000) - $gaps = [PurviewDlpReport.Coverage]::Gaps($Store.GetCoveredRanges($TargetId, 0), $windowStart, $NowMs - [long]$c.SettlingHours * 3600000) - $limit = $NowMs - [long]($c.SourceRetentionDays - $c.RetentionWarningDays) * 86400000 - foreach ($gap in $gaps) { - if ($gap.Start -lt $limit) { - [pscustomobject]@{ Start = $gap.Start; End = $gap.End; DaysLeft = [Math]::Max(0, [Math]::Floor(($gap.Start - ($NowMs - [long]$c.SourceRetentionDays * 86400000)) / 86400000.0)) } - } - } -} - -#endregion - -#region 5. Connection ------------------------------------------------------------------------ - -# ExchangeOnlineManagement versions that must not be used, by authentication mode (checked in the lab). -$script:ExoKnownIssues = @{ - '3.10.0' = @{ Modes = @('Certificate'); Issue = 'its certificate authentication fails with "Object reference not set to an instance of an object" (fixed in 3.10.1)' } -} - -function Select-DlpExoModule { - <# - .SYNOPSIS - Chooses the ExchangeOnlineManagement module to use: the version already loaded in the session, - otherwise the most recent installed version, 3.9.0 or later, without a known issue for the mode. - Returns the module and the versions skipped; throws a message with the command to run otherwise. - #> - param([AllowEmptyCollection()][object[]]$Available, [AllowNull()]$Loaded, [Parameter(Mandatory)][string]$Mode) - $install = 'Install-Module ExchangeOnlineManagement -MinimumVersion 3.10.1 -Scope CurrentUser -Force' - $problem = { - param($Version) - $known = $script:ExoKnownIssues[$Version.ToString()] - if ($Version -lt [version]'3.9.0') { return 'it is older than 3.9.0' } - if ($known -and $Mode -in $known.Modes) { return $known.Issue } - } - if ($Loaded) { - $why = & $problem $Loaded.Version - if ($why) { throw "ExchangeOnlineManagement $($Loaded.Version) is already loaded in this PowerShell session and cannot be used: $why. Open a new PowerShell window. If this version is loaded automatically, run: $install" } - return [pscustomobject]@{ Module = $Loaded; Skipped = @() } - } - $sorted = @($Available | Where-Object { $_ } | Sort-Object Version -Descending) - if (-not $sorted.Count) { throw "The ExchangeOnlineManagement module is not installed. Run: $install" } - $skipped = [Collections.Generic.List[string]]::new() - foreach ($m in $sorted) { - $why = & $problem $m.Version - if (-not $why) { return [pscustomobject]@{ Module = $m; Skipped = @($skipped) } } - if (-not $skipped.Contains("$($m.Version): $why")) { $skipped.Add("$($m.Version): $why") } - } - throw "No usable ExchangeOnlineManagement version is installed (3.9.2 and 3.10.1 validated). $(@($skipped) -join '; '). Run: $install" -} - -function Connect-DlpActivityExplorer { - <# - .SYNOPSIS - Connects to Security & Compliance PowerShell and returns the function used to call - Export-ActivityExplorerData. The tenant (and the account in interactive mode) is checked. - #> - [CmdletBinding()] - param([Parameter(Mandatory)]$Settings) - $auth = $Settings.Authentication - $choice = Select-DlpExoModule -Available @(Get-Module ExchangeOnlineManagement -ListAvailable) -Loaded (Get-Module ExchangeOnlineManagement | Select-Object -First 1) -Mode $auth.Mode - foreach ($s in $choice.Skipped) { Write-DlpItem Info "ExchangeOnlineManagement $s - skipped, $($choice.Module.Version) used." } - $module = $choice.Module - if (-not (Get-Module ExchangeOnlineManagement)) { Import-Module $module -Global -WarningAction SilentlyContinue } - Write-DlpLog 'INFO' "ExchangeOnlineManagement $($module.Version) ($($module.ModuleBase))" - - $prefix = $script:CommandPrefix - $existing = @(Get-ConnectionInformation -ErrorAction SilentlyContinue | Where-Object { $_.ModulePrefix -eq $prefix -and $_.State -eq 'Connected' }) - $connectedHere = $false - if (-not $existing) { - $parameters = @{ Prefix = $prefix; ShowBanner = $false; ErrorAction = 'Stop' } - if ($auth.Mode -eq 'Certificate') { - $parameters.AppId = $auth.AppId - $parameters.CertificateThumbprint = $auth.CertificateThumbprint - $parameters.Organization = $Settings.Tenant.Organization - } else { - if ($auth.UserPrincipalName) { $parameters.UserPrincipalName = $auth.UserPrincipalName } - if ($auth.DisableWAM -and (Get-Command Connect-IPPSSession).Parameters.ContainsKey('DisableWAM')) { $parameters.DisableWAM = $true } - } - Connect-IPPSSession @parameters | Out-Null - $connectedHere = $true - } - $connection = @(Get-ConnectionInformation | Where-Object { $_.ModulePrefix -eq $prefix -and $_.State -eq 'Connected' }) | Select-Object -Last 1 - if (-not $connection) { throw 'The Security & Compliance connection was not established.' } - $account = if ($auth.Mode -eq 'Certificate') { "app $($auth.AppId)" } else { [string]$connection.UserPrincipalName } - $problem = $null - if ($Settings.Tenant.TenantId -and [string]$connection.TenantID -ne $Settings.Tenant.TenantId) { $problem = "Connected to tenant $($connection.TenantID), but the configuration expects $($Settings.Tenant.TenantId)." } - elseif ($auth.Mode -eq 'Interactive' -and $auth.UserPrincipalName -and $account -ine $auth.UserPrincipalName) { $problem = "Signed in as $account, but the configuration expects $($auth.UserPrincipalName)." } - if ($problem) { - if ($connectedHere) { Disconnect-ExchangeOnline -ConnectionId $connection.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue } - throw $problem - } - $commandName = "Export-$($prefix)ActivityExplorerData" - $command = Get-Command $commandName -ErrorAction SilentlyContinue - if (-not $command) { - throw "Export-ActivityExplorerData is not available for $account. The account (or application) needs a Purview role that can export Activity Explorer data - see the guide, chapter 4 (accounts) and Annex F (application)." - } - $exporter = { param([hashtable]$Query) & $command @Query -ErrorAction Stop }.GetNewClosure() - [pscustomobject]@{ - Exporter = $exporter - Account = $account - TenantId = [string]$connection.TenantID - Mode = $auth.Mode - ConnectionId = $connection.ConnectionId - ConnectedHere = $connectedHere - } -} - -function Disconnect-DlpActivityExplorer { - param($Connection) - if ($Connection -and $Connection.ConnectedHere -and $Connection.ConnectionId) { - Disconnect-ExchangeOnline -ConnectionId $Connection.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null - } -} - -#endregion - -#region 6. Collection --------------------------------------------------------------------------- - -function Get-DlpProperty { - param($Object, [string]$Name) - if ($null -eq $Object) { return $null } - if ($Object -is [Collections.IDictionary]) { if ($Object.Contains($Name)) { return $Object[$Name] }; return $null } - $p = $Object.PSObject.Properties[$Name] - if ($p) { return $p.Value } - return $null -} - -function Test-DlpPageEnvelope { - <# - .SYNOPSIS - Checks one response of Export-ActivityExplorerData before its data is used. - .NOTES - Known false positive: the service fills ErrorData with the type name - "Microsoft.Exchange.Hygiene.DataInsights.Common.DataInsightsErrorData" even when - ResultCode is Success. That value is accepted only together with ResultCode = Success. - #> - param([Parameter(Mandatory)]$Response) - $resultCode = [string](Get-DlpProperty $Response 'ResultCode') - foreach ($field in 'Exception', 'ErrorData') { - $value = Get-DlpProperty $Response $field - if ($null -eq $value -or ($value -is [bool] -and -not $value)) { continue } - $text = ([string]$value).Trim() - if ($text -in '', '[]', '{}', 'null') { continue } - if ($field -eq 'ErrorData' -and $text -eq 'Microsoft.Exchange.Hygiene.DataInsights.Common.DataInsightsErrorData' -and $resultCode -eq 'Success') { continue } - throw "SourceEnvelopeError: $field = $text (ResultCode = $resultCode)." - } - $lastValue = Get-DlpProperty $Response 'LastPage' - if ($lastValue -is [bool]) { $last = $lastValue } - elseif ("$lastValue" -match '^(?i:true|false)$') { $last = [bool]::Parse("$lastValue") } - else { throw 'MalformedPage: LastPage is missing or is not a boolean.' } - $data = Get-DlpProperty $Response 'ResultData' - # Only .Length is used on ResultData here: passing a large string to a .NET method is slow (AMSI scan). - if ($data -isnot [string] -or $data.Length -eq 0) { throw 'MalformedPage: ResultData is missing (an empty page must be the JSON text []).' } - $recordCount = $null - $reported = Get-DlpProperty $Response 'RecordCount' - if ($null -ne $reported) { - $n = 0L - if (-not [long]::TryParse("$reported", [ref]$n) -or $n -lt 0) { throw 'MalformedPage: RecordCount is not a number.' } - $recordCount = $n - } - $total = $null - $reportedTotal = Get-DlpProperty $Response 'TotalResultCount' - if ($null -ne $reportedTotal) { $n = 0L; if ([long]::TryParse("$reportedTotal", [ref]$n)) { $total = $n } } - [pscustomobject]@{ - LastPage = $last - Watermark = [string](Get-DlpProperty $Response 'Watermark') - ResultData = $data - RecordCount = $recordCount - TotalResultCount = $total - ResultCode = $resultCode - } -} - -function Invoke-DlpCollection { - <# - .SYNOPSIS - Collects Activity Explorer events for a list of time slices and stores them in SQLite. - .DESCRIPTION - For each slice, pages are requested with the same filters (Exchange, DLPRuleMatch, - policy, rule) until LastPage = true. Every page is stored immediately, so an - interrupted execution never loses what was already received; the slice is marked - "Completed" only when its last page has been stored. - - On a recoverable error the slice is retried from its beginning (the paging cookie - cannot be reused after 120 s), then split in two halves. A permanent error - (permissions, command not found) stops the collection. - .OUTPUTS - An object with the counters and the final state (Completed / Error). - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Store, - [Parameter(Mandatory)][long]$RunId, - [Parameter(Mandatory)][long]$TargetId, - [Parameter(Mandatory)]$Settings, - [Parameter(Mandatory)][AllowEmptyCollection()]$Slices, - [Parameter(Mandatory)][scriptblock]$Exporter - ) - $c = $Settings.Collection - $zone = $Settings.Zone - $stats = [ordered]@{ - Completed = $false; Error = $null; Slices = @($Slices).Count; SlicesDone = 0; Pages = 0; Records = 0; NewEvents = 0 - Duplicates = 0; Quarantined = 0; Conflicts = 0; WithoutCount = 0; Restarts = 0; Subdivisions = 0 - SourceSeconds = 0.0; EngineSeconds = 0.0; Seconds = 0.0; ServiceAnnounced = 0; QuarantineReasons = @{} - } - $pending = [Collections.Generic.LinkedList[object]]::new() - foreach ($slice in $Slices) { [void]$pending.AddLast([pscustomobject]@{ Start = [long]$slice.Start; End = [long]$slice.End; Restart = 0 }) } - $totalSpan = [double](Get-DlpRangeLength $Slices) - $doneSpan = 0.0 - $clock = [Diagnostics.Stopwatch]::StartNew() - $pageData = [PurviewDlpReport.PageData]::new() - $permanent = '(?i)(not recognized|CommandNotFound|ParameterBinding|access.{0,20}denied|forbidden|\b403\b|not authori[sz]ed|insufficient|does not have permission)' - $index = 0 - if ($pending.Count) { Write-DlpTableRow -Header } - try { - while ($pending.Count) { - $slice = $pending.First.Value; $pending.RemoveFirst() - $index++ - $label = Format-DlpRange $slice.Start $slice.End $zone - $intervalId = $Store.BeginInterval($RunId, $TargetId, $slice.Start, $slice.End) - $pages = 0; $records = 0L; $new = 0L; $serviceTotal = $null; $cookie = $null - $cookies = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) - $hashes = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) - $sinceLastPage = $null - $sliceClock = [Diagnostics.Stopwatch]::StartNew() - Write-DlpLog 'INFO' "Slice $label started (interval $intervalId, attempt $($slice.Restart + 1))" - try { - while ($true) { - if ($pages -ge $c.MaxPagesPerSlice) { throw "SlicePageBudgetExceeded: more than $($c.MaxPagesPerSlice) pages for one window." } - if ($sinceLastPage -and $sinceLastPage.Elapsed.TotalSeconds -ge $c.CookieSafetySeconds) { throw 'PageCookieExpiredLocalSafetyBudget: the next page could not be requested within the cookie lifetime.' } - $query = @{ - StartTime = [DateTimeOffset]::FromUnixTimeMilliseconds($slice.Start).UtcDateTime - EndTime = [DateTimeOffset]::FromUnixTimeMilliseconds($slice.End).UtcDateTime - OutputFormat = 'Json' - PageSize = $c.PageSize - Filter1 = @('Workload', 'Exchange') - Filter2 = @('Activity', 'DLPRuleMatch') - Filter3 = @('DLPPolicyId', $Settings.Target.PolicyId) - Filter4 = @('DLPPolicyRuleId', $Settings.Target.RuleId) - } - if ($cookie) { $query.PageCookie = $cookie } - $call = [Diagnostics.Stopwatch]::StartNew() - try { $responses = @(& $Exporter $query) } - finally { $stats.SourceSeconds += $call.Elapsed.TotalSeconds } - $sinceLastPage = [Diagnostics.Stopwatch]::StartNew() - if ($responses.Count -ne 1) { throw "MalformedPage: one response expected, $($responses.Count) received." } - $page = Test-DlpPageEnvelope -Response $responses[0] - $pageData.ResultData = $page.ResultData # property assignment, see PageData in the engine - $result = $Store.IngestPage($intervalId, $TargetId, $Settings.Target.PolicyId, $Settings.Target.RuleId, $pageData) - $pages++ - $stats.EngineSeconds += ($result.ParseMs + $result.NormalizeMs + $result.WriteMs + $result.CommitMs) / 1000 - if ($result.Records -gt $c.PageSize) { throw 'MalformedPage: more records than the requested page size.' } - if ($null -ne $page.RecordCount -and $page.RecordCount -ne $result.Records) { throw "MalformedPage: RecordCount $($page.RecordCount) differs from the $($result.Records) records received." } - if ($result.Records -eq 0 -and -not $page.LastPage) { throw 'EmptyIntermediatePage: an empty page that is not the last one cannot be trusted.' } - if ($result.Records -gt 0 -and -not $hashes.Add($result.Sha256)) { throw 'RepeatedPage: the service returned the same page twice.' } - $records += $result.Records; $new += $result.NewEvents - $stats.Pages++; $stats.Records += $result.Records; $stats.NewEvents += $result.NewEvents; $stats.Duplicates += $result.Duplicates - $stats.Quarantined += $result.Quarantined; $stats.Conflicts += $result.Conflicts; $stats.WithoutCount += $result.WithoutCount - foreach ($reason in $result.QuarantineReasons.Keys) { $stats.QuarantineReasons[$reason] = [int]$stats.QuarantineReasons[$reason] + $result.QuarantineReasons[$reason] } - if ($null -eq $serviceTotal -and $null -ne $page.TotalResultCount) { $serviceTotal = [long]$page.TotalResultCount } - - # Progress: share of the total time span, refined with the announced number of events. - $fraction = if ($serviceTotal -gt 0) { [Math]::Min(1.0, $records / $serviceTotal) } else { 0.0 } - $percent = if ($totalSpan -gt 0) { [Math]::Min(100.0, 100.0 * ($doneSpan + $fraction * ($slice.End - $slice.Start)) / $totalSpan) } else { 100 } - $rate = if ($clock.Elapsed.TotalSeconds -gt 0) { $stats.Records / $clock.Elapsed.TotalSeconds } else { 0 } - $eta = if ($percent -gt 1) { Format-DlpDuration ($clock.Elapsed.TotalSeconds * (100 - $percent) / $percent) } else { 'estimating...' } - Write-Progress -Id 1 -Activity 'Collecting Activity Explorer data' -PercentComplete $percent ` - -Status ("Window {0}/{1} {2} | {3} / {4} events | {5:0} events/s | remaining {6}" -f $index, ($index + $pending.Count), $label, (Format-DlpNumber $records), (Format-DlpNumber $serviceTotal), $rate, $eta) - - if ($page.LastPage) { break } - if (-not $page.Watermark) { throw 'MissingCookie: LastPage is false but no Watermark was returned.' } - if (-not $cookies.Add($page.Watermark)) { throw 'RepeatedCookie: the paging cookie did not change.' } - $cookie = $page.Watermark - } - $Store.EndInterval($intervalId, $true, $pages, $records, $new, $serviceTotal, $null) - $doneSpan += ($slice.End - $slice.Start) - $stats.SlicesDone++ - if ($serviceTotal) { $stats.ServiceAnnounced += $serviceTotal } - $sliceRate = if ($sliceClock.Elapsed.TotalSeconds -gt 0) { $records / $sliceClock.Elapsed.TotalSeconds } else { 0 } - Write-DlpTableRow -Status Ok -Window $label -Events $records -New $new -Pages $pages -Duration (Format-DlpDuration $sliceClock.Elapsed.TotalSeconds) -Rate ("{0:0} ev/s" -f $sliceRate) - if ($null -ne $serviceTotal -and $serviceTotal -ne $records) { Write-DlpLog 'INFO' "Slice $label : service announced $serviceTotal events, $records received (the announced total is known to be approximate)." } - } catch { - $message = $_.Exception.Message - $Store.EndInterval($intervalId, $false, $pages, $records, $new, $serviceTotal, $message) - Write-DlpLog 'WARN' "Slice $label failed after $pages page(s): $message" - $length = $slice.End - $slice.Start - if ($message -match $permanent) { throw "$label : $message" } - if ($slice.Restart -lt $c.MaxSliceRestarts) { - $stats.Restarts++ - $delay = if ($message -match '(?i)(429|throttl)') { 60 } else { 5 * [Math]::Pow(2, $slice.Restart) } - Write-DlpItem Warn ("{0} interrupted ({1}) - retry {2}/{3} in {4} s" -f $label, (($message -split "`n")[0]), ($slice.Restart + 1), $c.MaxSliceRestarts, $delay) - Start-Sleep -Seconds $delay - [void]$pending.AddFirst([pscustomobject]@{ Start = $slice.Start; End = $slice.End; Restart = $slice.Restart + 1 }) - } elseif ($length -ge 2 * [long]$c.MinimumSliceMinutes * 60000) { - $stats.Subdivisions++ - $middle = $slice.Start + [long][Math]::Floor($length / 2) - Write-DlpItem Warn ("{0} still failing - split into two smaller windows" -f $label) - [void]$pending.AddFirst([pscustomobject]@{ Start = $middle; End = $slice.End; Restart = 0 }) - [void]$pending.AddFirst([pscustomobject]@{ Start = $slice.Start; End = $middle; Restart = 0 }) - } else { throw "$label : $message" } - } - } - $stats.Completed = $true - } catch { - $stats.Error = $_.Exception.Message - } finally { - Write-Progress -Id 1 -Activity 'Collecting Activity Explorer data' -Completed - $stats.Seconds = $clock.Elapsed.TotalSeconds - } - return [pscustomobject]$stats -} - -#endregion - -#region 7. Report -------------------------------------------------------------------------------- - -function Move-DlpDirectory { - <# Directory move with retries: antivirus or indexing can briefly lock files that were just written. #> - param([Parameter(Mandatory)][string]$Source, [Parameter(Mandatory)][string]$Destination, [int]$Attempts = 10) - for ($i = 1; $i -le $Attempts; $i++) { - try { [IO.Directory]::Move($Source, $Destination); return } - catch { - $e = $_.Exception; while ($e.InnerException) { $e = $e.InnerException } - $code = $e.HResult -band 0xFFFF - $transient = $e -is [UnauthorizedAccessException] -or $code -in 5, 32, 33 - if (-not $transient -or $i -eq $Attempts) { throw } - Start-Sleep -Milliseconds (250 * $i) - } - } -} - -function New-DlpReport { - <# - .SYNOPSIS - Writes the CSV and HTML files of a period from the database. - .DESCRIPTION - Files are written in a ".pending" directory, which is renamed only when - every file is complete: a folder without ".pending" is always a finished report. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, [Parameter(Mandatory)]$Settings, - [Parameter(Mandatory)]$Period, [Parameter(Mandatory)]$Coverage - ) - $r = $Settings.Report - $zone = $Settings.Zone - [void][IO.Directory]::CreateDirectory($r.OutputPath) - $name = '{0:yyyy-MM-dd_HHmmss}_{1}' -f (Get-Date), $Period.Range - $final = Join-Path $r.OutputPath $name - for ($n = 2; (Test-Path -LiteralPath $final) -or (Test-Path -LiteralPath "$final.pending"); $n++) { $final = Join-Path $r.OutputPath "${name}_$n" } - $staging = "$final.pending" - $request = [PurviewDlpReport.ReportRequest]::new() - $request.StartMs = $Period.StartMs - $request.EndMs = $Period.EndMs - $request.Zone = $zone - $request.TimeZoneLabel = $r.TimeZone - $request.SplitBy = $r.SplitBy - $request.MaxRowsPerFile = $r.MaxRowsPerFile - $request.IncludeRecipientDetails = [bool]$r.IncludeRecipientDetails - $request.WriteCsv = 'Csv' -in $r.Formats - $request.WriteHtml = 'Html' -in $r.Formats - $request.CsvDelimiter = $r.CsvDelimiter - $request.OutputDirectory = $staging - $request.FilePrefix = $r.FilePrefix - $request.HtmlTemplatePath = $r.TemplatePath - $request.Title = $r.Title - $request.PolicyName = $Settings.Target.PolicyName - $request.RuleName = $Settings.Target.RuleName - $request.ToolVersion = $script:ToolVersion - $request.RangeName = $Period.Range - $request.CoveragePercent = $Coverage.Percent - if ($Coverage.Percent -lt 100) { - $request.CoverageNote = 'no data collected for ' + ((@($Coverage.Gaps) | Select-Object -First 3 | ForEach-Object { Format-DlpRange $_.Start $_.End $zone }) -join ', ') + $(if (@($Coverage.Gaps).Count -gt 3) { ', ...' } else { '' }) - } - $result = $Store.WriteReport($TargetId, $request) - Move-DlpDirectory -Source $staging -Destination $final - foreach ($file in $result.Files) { $file.Path = Join-Path $final (Split-Path $file.Path -Leaf) } - [pscustomobject]@{ Directory = $final; Result = $result } -} - -#endregion - -#region 8. Status and maintenance ------------------------------------------------------------------ - -function Show-DlpStatus { - <# - .SYNOPSIS - Prints the database content and, day by day, what has been collected: - date, messages, a coverage bar and the state (complete, provisional, missing, lost). - #> - [CmdletBinding()] - param([Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, [Parameter(Mandatory)]$Settings, [int]$Days = 35) - $zone = $Settings.Zone; $cfg = $Settings.Collection; $K = $script:C; $dot = [char]0x00B7 - $now = [DateTimeOffset]::UtcNow - $nowMs = $now.ToUnixTimeMilliseconds() - $s = $Store.GetStatistics($TargetId) - Write-DlpItem Info ('{0} ({1})' -f $Store.DatabasePath, (Format-DlpBytes $s.FileBytes)) -Icon Database - Write-DlpItem Info ('{0} events {4} {1} messages {4} {2} executions {4} {3} quarantined record(s)' -f (Format-DlpNumber $s.Events), (Format-DlpNumber $s.Messages), (Format-DlpNumber $s.Runs), (Format-DlpNumber $s.Quarantined), $dot) -Icon Chart - if ($null -eq $s.FirstCoveredMs) { Write-DlpItem Warn 'Nothing has been collected yet. Run: .\Invoke-PurviewDlpReport.ps1 -Mode Collect'; return } - Write-DlpItem Info ('History {0} {1} last collection {2}' -f (Format-DlpRange $s.FirstCoveredMs $s.LastCoveredMs $zone), $dot, (Format-DlpLocalTime $s.LastSuccessfulCollectionMs $zone 'yyyy-MM-dd HH:mm:ss')) -Icon Calendar - Write-Host '' - $today = [TimeZoneInfo]::ConvertTime($now, $zone).DateTime.Date - $first = $today.AddDays( - ($Days - 1)) - $firstCollected = [TimeZoneInfo]::ConvertTime([DateTimeOffset]::FromUnixTimeMilliseconds($s.FirstCoveredMs), $zone).DateTime.Date - if ($firstCollected -gt $first) { $first = $firstCollected } - $Days = [int]($today - $first).TotalDays + 1 - $rows = $Store.GetDailyStatistics($TargetId, $first, $Days, $zone, [long]$cfg.SettlingHours * 3600000) - $sourceStart = $nowMs - [long]$cfg.SourceRetentionDays * 86400000 - $en = [Globalization.CultureInfo]::GetCultureInfo('en-US') - Write-Host (' {0}{1}{2,-15} {3,10} {4,-16} {5}{6}' -f $K.Dim, (' ' + $script:IconPad), 'Day', 'Messages', 'Collected', 'State', $K.Reset) - foreach ($d in $rows) { - if ($d.StartMs -ge $nowMs) { continue } - $length = [Math]::Max(1, [Math]::Min($d.EndMs, $nowMs) - $d.StartMs) - $percent = [int][Math]::Min(100, [Math]::Floor(100.0 * $d.CoveredMs / $length)) - # State, status icon and colour of the day. - if ($d.LocalDate -eq $today) { $state = if ($percent -ge 100) { 'Today, provisional' } else { 'Today, collection in progress' }; $status = 'Info' } - elseif ($percent -ge 100 -and $d.FinalCoveredMs -ge ($d.EndMs - $d.StartMs)) { $state = 'Complete'; $status = 'Ok' } - elseif ($percent -ge 100) { $state = 'Complete, refreshed at next run'; $status = 'Ok' } - elseif ($d.EndMs -le $sourceStart) { $state = if ($percent -gt 0) { 'Partial, rest no longer collectable' } else { 'Missing, no longer collectable' }; $status = 'Fail' } - elseif ($d.StartMs -lt $sourceStart) { $state = "Partial, earlier hours beyond the $($cfg.SourceRetentionDays)-day retention"; $status = 'Info' } - else { - $left = [Math]::Max(0, [Math]::Floor(($d.StartMs - $sourceStart) / 86400000.0)) - $state = '{0}, collectable {1} more day(s)' -f $(if ($percent -gt 0) { 'Partial' } else { 'Missing' }), $left; $status = 'Warn' - } - $color = @{ Ok = $K.Green; Warn = $K.Yellow; Fail = $K.Red; Info = $K.Cyan }[$status] - # Coverage bar: 12 cells. - $filled = [int][Math]::Round(12 * $percent / 100.0) - $bar = $color + [string]::new([char]0x2588, $filled) + $K.Dim + [string]::new([char]0x2591, 12 - $filled) + $K.Reset - $day = '{0} {1}' -f $d.LocalDate.ToString('ddd', $en), $d.LocalDate.ToString('yyyy-MM-dd') - $messages = if ($d.Messages) { $K.Bold + (Format-DlpNumber $d.Messages).PadLeft(10) + $K.Reset } else { $K.Dim + '0'.PadLeft(10) + $K.Reset } - Write-Host (' {0}{1}{2}{3,-15} {4} {5} {6,4}% {0}{7}{2}' -f $color, (Get-DlpIcon $status), $K.Reset, $day, $messages, $bar, $percent, $state) - Write-DlpLog 'INFO' ("Status {0}: {1} messages, {2} events, {3}% collected, {4}" -f $d.LocalDate.ToString('yyyy-MM-dd'), $d.Messages, $d.Events, $percent, $state) - } -} -function Invoke-DlpRetention { - <# Deletes database content older than Storage.RetentionDays (0 = keep everything). #> - param([Parameter(Mandatory)]$Store, [Parameter(Mandatory)]$Settings) - if ($Settings.Storage.RetentionDays -le 0) { return $null } - $cutoff = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - [long]$Settings.Storage.RetentionDays * 86400000 - return $Store.PurgeBefore($cutoff) -} - -function Enter-DlpLock { - <# - .SYNOPSIS - Prevents two executions from collecting at the same time (for example the scheduled - task and an administrator). Returns the lock, to be released with Exit-DlpLock. - #> - param([Parameter(Mandatory)][string]$Path, [int]$TimeoutSeconds = 30) - [void][IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) - $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) - while ($true) { - try { - $stream = [IO.FileStream]::new($Path, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read) - $stream.SetLength(0) - $bytes = [Text.Encoding]::UTF8.GetBytes(("{0} pid {1} since {2:o}" -f [Environment]::MachineName, $PID, (Get-Date))) - $stream.Write($bytes, 0, $bytes.Length); $stream.Flush() - return $stream - } catch [IO.IOException] { - if ([DateTime]::UtcNow -ge $deadline) { - $owner = try { [IO.File]::ReadAllText($Path) } catch { 'unknown' } - throw "Another execution is already collecting ($owner). Wait for it to finish, or use -NoCollect to build a report from the data already collected." - } - Start-Sleep -Seconds 2 - } - } -} - -function Exit-DlpLock { - param($Lock) - if ($Lock) { $Lock.Dispose() } -} - -#endregion +#Requires -Version 7.4 +<# +.SYNOPSIS + Purview DLP Report - PowerShell module. + +.DESCRIPTION + Helper functions used by Invoke-PurviewDlpReport.ps1. The module is organised + in regions, in the order of an execution: + + 1. Console and log Write-Dlp* functions (what the administrator sees) + 2. Configuration Import-DlpConfiguration (reads and checks the .psd1 file) + 3. Engine Initialize-DlpEngine (SQLite + compiled C# engine) + 4. Periods and coverage Resolve-DlpPeriod, Get-DlpCollectionPlan, Get-DlpCoverage + 5. Connection Connect-DlpActivityExplorer / Disconnect-DlpActivityExplorer + 6. Collection Invoke-DlpCollection (Activity Explorer -> SQLite) + 7. Report New-DlpReport (SQLite -> CSV / HTML) + 8. Status and maintenance Show-DlpStatus, Invoke-DlpRetention, Enter-DlpLock + + Performance-critical work (JSON parsing, database, file writing) is done by + src\PurviewDlpReport.Engine.cs, compiled on first use. + +.NOTES + Author : Nicolas Fabert + Version : 2.1.2 + History : see CHANGELOG.md +#> +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:ToolVersion = '2.1.2' +$script:ToolRoot = $PSScriptRoot +function Assert-DlpPackageIntegrity { + $verifier = Join-Path $script:ToolRoot 'Test-PackageIntegrity.ps1' + $cursor = [IO.Path]::GetFullPath($verifier) + while ($cursor) { + $item = Microsoft.PowerShell.Management\Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Package integrity: reparse point: $cursor" + } + $cursor = [IO.Path]::GetDirectoryName($cursor) + } + $signature = Microsoft.PowerShell.Security\Get-AuthenticodeSignature -LiteralPath $verifier -ErrorAction Stop + if ([string]$signature.Status -cne 'Valid' -or $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -cne '0F28E442889F17B5F4D251E8F96E7CC6E2C9C6D6' -or + $null -eq $signature.TimeStamperCertificate) { + throw 'Package integrity: verifier must have the trusted, timestamped owner signature.' + } + & $verifier -Root $script:ToolRoot +} +Assert-DlpPackageIntegrity +# Prefix given to the Security & Compliance cmdlets imported by Connect-IPPSSession. +# It avoids any clash with another Exchange/Purview session opened in the same console. +$script:CommandPrefix = 'PdrAe' +$script:LogWriter = $null +$script:LogPath = $null +# --------------------------------------------------------------------------------------------- +# Console theme: colours and icons. +# - Colours (ANSI) are disabled when the output is redirected (scheduled task, log capture) +# or when the NO_COLOR environment variable is set; PDR_FORCE_COLOR=1 forces them. +# - Icons: emoji in modern terminals (Windows Terminal, VS Code), simple symbols elsewhere. +# Emoji are chosen among those always two columns wide (no variation selector), so frames +# and columns stay aligned. The classic console (conhost) has no font fallback: a character +# missing from its font is shown as an empty box. The symbols used outside the modern +# terminals are therefore all present in Consolas and Lucida Console (code page 437 +# repertoire or Latin-1) - a test checks it. Force a style with the environment +# variable PDR_ICONS = Emoji | Symbols | Ascii. +# - Frames: rounded corners, present in Consolas (default font of the console). Lucida Console +# and the raster font have no rounded corners: square corners are used with them (the +# console font is read when the frame is drawn, see Get-DlpFrame). +# --------------------------------------------------------------------------------------------- +$script:C = @{ Reset = ''; Bold = ''; Dim = ''; Accent = ''; AccentBg = ''; Cyan = ''; Green = ''; Yellow = ''; Red = ''; White = '' } +if ($env:PDR_FORCE_COLOR -eq '1' -or (-not [Console]::IsOutputRedirected -and -not $env:NO_COLOR)) { + $e = [char]27 + $script:C = @{ + Reset = "$e[0m"; Bold = "$e[1m"; Dim = "$e[90m"; White = "$e[97m" + Accent = "$e[38;2;214;62;115m"; AccentBg = "$e[48;2;177;31;75m$e[97m" + Cyan = "$e[38;2;97;214;214m"; Green = "$e[38;2;80;200;120m"; Yellow = "$e[38;2;240;200;90m"; Red = "$e[38;2;240;90;90m" + } +} +$script:IconStyle = if ($env:PDR_ICONS -in 'Emoji', 'Symbols', 'Ascii') { $env:PDR_ICONS } + elseif ([Console]::IsOutputRedirected) { 'Symbols' } + elseif ($env:WT_SESSION -or $env:TERM_PROGRAM -eq 'vscode') { 'Emoji' } + else { 'Symbols' } + +function Get-DlpIconSet { + <# Icons of one console style. Symbols: only characters of the classic console fonts. #> + param([Parameter(Mandatory)][ValidateSet('Emoji', 'Symbols', 'Ascii')][string]$Style) + $u = { param([int]$Code) [char]::ConvertFromUtf32($Code) } + switch ($Style) { + 'Emoji' { return @{ + Logo = & $u 0x1F512; Ok = & $u 0x2705; Warn = (& $u 0x26A0) + [char]0xFE0F + Fail = & $u 0x274C; Info = & $u 0x1F539; Skip = & $u 0x23E9 + Database = & $u 0x1F4BE; Plan = & $u 0x1F50E; Key = & $u 0x1F510 + Download = & $u 0x1F4E5; Report = & $u 0x1F4CA; Calendar = & $u 0x1F4C5 + File = & $u 0x1F4C4; Folder = & $u 0x1F4C1; Clock = & $u 0x23F3 + Mail = & $u 0x1F4E8; Target = & $u 0x1F3AF; Log = & $u 0x1F4DD + Done = & $u 0x1F389; People = & $u 0x1F465; Chart = & $u 0x1F4C8 + } } + 'Symbols' { return @{ + Logo = & $u 0x2666; Ok = & $u 0x221A; Warn = & $u 0x25B2; Fail = & $u 0x00D7; Info = & $u 0x2022 + Skip = & $u 0x00BB; Database = & $u 0x25A0; Plan = & $u 0x25BA; Key = & $u 0x2194; Download = & $u 0x2193 + Report = & $u 0x2261; Calendar = & $u 0x263C; File = & $u 0x25AC; Folder = & $u 0x2302; Clock = & $u 0x25CB + Mail = '@'; Target = & $u 0x25D9; Log = & $u 0x00B6; Done = & $u 0x221A; People = & $u 0x2192; Chart = & $u 0x2191 + } } + default { return @{ + Logo = '*'; Ok = '+'; Warn = '!'; Fail = 'x'; Info = '-'; Skip = '>'; Database = '#'; Plan = '?'; Key = '@'; Download = 'v'; Report = '='; Calendar = ':' + File = '-'; Folder = '>'; Clock = '~'; Mail = '@'; Target = 'o'; Log = '='; Done = '*'; People = '&'; Chart = '^' + } } + } +} + +function Get-DlpFrameSet { + <# + .SYNOPSIS + Frame characters. Rounded corners, except with the Ascii style and with the console fonts + that have no rounded corners (Lucida Console, raster font 'Terminal'): square corners. + #> + param([Parameter(Mandatory)][ValidateSet('Emoji', 'Symbols', 'Ascii')][string]$Style, [AllowNull()][string]$FontName) + if ($Style -eq 'Ascii') { return @{ TopLeft = [char]'+'; TopRight = [char]'+'; BottomLeft = [char]'+'; BottomRight = [char]'+'; Horizontal = [char]'-'; Vertical = [char]'|' } } + if ($Style -eq 'Symbols' -and $FontName -in 'Lucida Console', 'Terminal') { + return @{ TopLeft = [char]0x250C; TopRight = [char]0x2510; BottomLeft = [char]0x2514; BottomRight = [char]0x2518; Horizontal = [char]0x2500; Vertical = [char]0x2502 } + } + return @{ TopLeft = [char]0x256D; TopRight = [char]0x256E; BottomLeft = [char]0x2570; BottomRight = [char]0x256F; Horizontal = [char]0x2500; Vertical = [char]0x2502 } +} + +function Get-DlpFrame { + <# Frame characters for this console. In the classic console the font is read once, through the engine. #> + if ($script:Frame) { return $script:Frame } + $engine = [bool]('PurviewDlpReport.ConsoleFont' -as [type]) + $font = if ($script:IconStyle -eq 'Symbols' -and $engine) { [PurviewDlpReport.ConsoleFont]::FaceName() } + $frame = Get-DlpFrameSet $script:IconStyle $font + # Before the engine is loaded (error at start) the font is unknown: not kept, read again later. + if ($script:IconStyle -ne 'Symbols' -or $engine) { $script:Frame = $frame } + return $frame +} + +$script:Icons = Get-DlpIconSet $script:IconStyle +$script:Frame = $null +# Emoji are two columns wide in the console; symbols are one: pad symbols so text stays aligned. +$script:IconPad = if ($script:IconStyle -eq 'Emoji') { ' ' } else { ' ' } + +#region 1. Console and log --------------------------------------------------------------- + +function Get-DlpIcon { + param([Parameter(Mandatory)][string]$Name) + return $script:Icons[$Name] + $script:IconPad +} + +function Format-DlpNumber { + param([Parameter(Mandatory)][AllowNull()]$Value) + if ($null -eq $Value) { return '-' } + return ([long]$Value).ToString('N0', [Globalization.CultureInfo]::GetCultureInfo('en-US')) +} + +function Format-DlpDuration { + param([Parameter(Mandatory)][double]$Seconds) + # 0.0 (not 0): with an integer first argument PowerShell picks Math.Max(int, int) and drops the decimals. + $t = [TimeSpan]::FromTicks([long]([Math]::Max(0.0, $Seconds) * 10000000)) + if ($t.TotalDays -ge 2) { return '{0} d {1:00} h' -f [int][Math]::Floor($t.TotalDays), $t.Hours } + if ($t.TotalHours -ge 1) { return '{0} h {1:00} min' -f [int][Math]::Floor($t.TotalHours), $t.Minutes } + if ($t.TotalMinutes -ge 1) { return '{0} min {1:00} s' -f $t.Minutes, $t.Seconds } + return '{0:0.0} s' -f $t.TotalSeconds +} + +function Format-DlpBytes { + param([Parameter(Mandatory)][double]$Bytes) + if ($Bytes -ge 1GB) { return '{0:0.00} GB' -f ($Bytes / 1GB) } + if ($Bytes -ge 1MB) { return '{0:0.0} MB' -f ($Bytes / 1MB) } + if ($Bytes -ge 1KB) { return '{0:0} KB' -f ($Bytes / 1KB) } + return '{0:0} B' -f $Bytes +} + +function Format-DlpLocalTime { + param([Parameter(Mandatory)][long]$UnixMs, [Parameter(Mandatory)][TimeZoneInfo]$Zone, [string]$Format = 'yyyy-MM-dd HH:mm') + return [TimeZoneInfo]::ConvertTime([DateTimeOffset]::FromUnixTimeMilliseconds($UnixMs), $Zone).ToString($Format, [Globalization.CultureInfo]::InvariantCulture) +} + +function Format-DlpRange { + param([long]$StartMs, [long]$EndMs, [TimeZoneInfo]$Zone) + return '{0} {2} {1}' -f (Format-DlpLocalTime $StartMs $Zone), (Format-DlpLocalTime $EndMs $Zone), [char]0x2192 +} + +function Start-DlpLog { + <# Opens (or continues) today's log file and deletes log files older than the retention. #> + param([Parameter(Mandatory)][string]$Directory, [int]$RetentionDays = 30) + [void][IO.Directory]::CreateDirectory($Directory) + $script:LogPath = Join-Path $Directory ('PurviewDlpReport_{0:yyyyMMdd}.log' -f (Get-Date)) + $stream = [IO.FileStream]::new($script:LogPath, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::ReadWrite) + $script:LogWriter = [IO.StreamWriter]::new($stream, [Text.UTF8Encoding]::new($false)) + $script:LogWriter.AutoFlush = $true + $limit = (Get-Date).AddDays(-$RetentionDays) + Get-ChildItem -LiteralPath $Directory -Filter 'PurviewDlpReport_*.log' -File -ErrorAction SilentlyContinue | + Where-Object LastWriteTime -lt $limit | Remove-Item -Force -ErrorAction SilentlyContinue + return $script:LogPath +} + +function Stop-DlpLog { + if ($script:LogWriter) { $script:LogWriter.Dispose(); $script:LogWriter = $null } +} + +function Write-DlpLog { + <# Writes one line to the log file only (never to the console). The log never contains colours or icons. #> + param([ValidateSet('INFO', 'OK', 'WARN', 'ERROR', 'STEP', 'DEBUG')][string]$Level = 'INFO', [Parameter(Mandatory)][AllowEmptyString()][string]$Message) + if ($script:LogWriter) { + $script:LogWriter.WriteLine(('{0:yyyy-MM-ddTHH:mm:ss.fffzzz} [{1,-5}] {2}' -f (Get-Date), $Level, $Message)) + } +} + +function Write-DlpBanner { + <# + .SYNOPSIS + Title card at the start of an execution: + + ╭──────────────────────────────────────────────────────────────────────╮ + │ 🛡 Purview DLP Report v2.0.0 · Nicolas Fabert │ + │ Exchange Online DLP matches · one row per Message ID │ + ╰──────────────────────────────────────────────────────────────────────╯ + 📅 Period 2026-09-22 19:58 → 2026-09-29 19:58 ... + .PARAMETER Details + Ordered list of rows: key = label, value = @(IconName, Text) or plain text. + #> + param([Parameter(Mandatory)][string]$Title, [string]$Subtitle, [System.Collections.Specialized.OrderedDictionary]$Details) + $C = $script:C; $F = Get-DlpFrame; $width = 74 + $right = "v$($script:ToolVersion) $([char]0x00B7) Nicolas Fabert" + $iconWidth = if ($script:IconStyle -eq 'Emoji') { 2 } else { 1 } + $left = " $($script:Icons.Logo) $Title" + $gap = [Math]::Max(1, $width - ($left.Length - $script:Icons.Logo.Length + $iconWidth) - $right.Length - 2) + Write-Host '' + Write-Host (" {0}{1}{2}{3}{4}" -f $C.Accent, $F.TopLeft, [string]::new($F.Horizontal, $width), $F.TopRight, $C.Reset) + Write-Host (" {0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}{11}" -f $C.Accent, $F.Vertical, $C.Reset, $C.Bold, $left, $C.Reset, [string]::new(' ', $gap), $C.Dim, $right, ' ', ($C.Accent + $F.Vertical), $C.Reset) + if ($Subtitle) { + $sub = " $Subtitle" + Write-Host (" {0}{1}{2}{3}{4}{5}{0}{6}{2}" -f $C.Accent, $F.Vertical, $C.Reset, $C.Dim, $sub.PadRight($width), $C.Reset, $F.Vertical) + } + Write-Host (" {0}{1}{2}{3}{4}" -f $C.Accent, $F.BottomLeft, [string]::new($F.Horizontal, $width), $F.BottomRight, $C.Reset) + if ($Details) { + foreach ($key in $Details.Keys) { + $value = $Details[$key] + $icon, $text = if ($value -is [array]) { (Get-DlpIcon $value[0]), $value[1] } else { ' ', $value } + Write-Host (" {0}{1}{2,-10}{3} {4}" -f $icon, $C.Dim, $key, $C.Reset, $text) + } + } + Write-DlpLog 'STEP' "=== $Title v$($script:ToolVersion) ===" + if ($Details) { foreach ($key in $Details.Keys) { $v = $Details[$key]; Write-DlpLog 'INFO' ("{0}: {1}" -f $key, $(if ($v -is [array]) { $v[1] } else { $v })) } } +} + +function Write-DlpStep { + <# + .SYNOPSIS + Step header with a coloured number pill and an icon, e.g. + + ─── 3/5 ─ 🔐 Connecting to Security & Compliance PowerShell + #> + param([Parameter(Mandatory)][int]$Number, [Parameter(Mandatory)][int]$Total, [Parameter(Mandatory)][string]$Title, [string]$Icon = 'Info') + $C = $script:C + Write-Host '' + Write-Host (" {0} {1}/{2} {3} {4}{5}{6}{3}" -f $C.AccentBg, $Number, $Total, $C.Reset, (Get-DlpIcon $Icon), $C.Bold, $Title) + Write-DlpLog 'STEP' "[$Number/$Total] $Title" +} + +function Write-DlpItem { + <# One indented result line with a status icon, also written to the log. #> + param([ValidateSet('Ok', 'Warn', 'Fail', 'Info', 'Skip')][string]$Status = 'Info', [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Icon) + $color = @{ Ok = $script:C.Green; Warn = $script:C.Yellow; Fail = $script:C.Red; Info = ''; Skip = $script:C.Dim }[$Status] + $level = @{ Ok = 'OK'; Warn = 'WARN'; Fail = 'ERROR'; Info = 'INFO'; Skip = 'INFO' }[$Status] + $symbol = Get-DlpIcon $(if ($Icon) { $Icon } else { $Status }) + $textColor = if ($Status -in 'Warn', 'Fail', 'Skip') { $color } else { '' } + Write-Host (" {0}{1}{2}{3}{4}{2}" -f $color, $symbol, $script:C.Reset, $textColor, $Text) + Write-DlpLog $level $Text +} + +function Write-DlpTableRow { + <# + .SYNOPSIS + One aligned row of the collection table (one row per Activity Explorer window). + Columns: status icon, window, events, new, pages, duration, rate. + #> + param([switch]$Header, [ValidateSet('Ok', 'Warn', 'Fail')][string]$Status = 'Ok', [string]$Window, $Events, $New, $Pages, [string]$Duration, [string]$Rate) + $C = $script:C + if ($Header) { + Write-Host (" {0}{1}{2,-35} {3,10} {4,10} {5,6} {6,12} {7,9}{8}" -f $C.Dim, (' ' + $script:IconPad), 'Window', 'Events', 'New', 'Pages', 'Duration', 'Rate', $C.Reset) + return + } + $color = @{ Ok = $C.Green; Warn = $C.Yellow; Fail = $C.Red }[$Status] + $eventsText = Format-DlpNumber $Events + $eventsColor = if ([long]$Events -gt 0) { $C.White + $C.Bold } else { $C.Dim } + Write-Host (" {0}{1}{2}{3,-35} {4}{5,10}{2} {6,10} {7,6} {8,12} {9}{10,9}{2}" -f $color, (Get-DlpIcon $Status), $C.Reset, $Window, $eventsColor, $eventsText, (Format-DlpNumber $New), $Pages, $Duration, $C.Dim, $Rate) + Write-DlpLog $(if ($Status -eq 'Ok') { 'OK' } else { 'WARN' }) ("{0} {1} events, {2} new, {3} pages, {4}, {5}" -f $Window, $eventsText, (Format-DlpNumber $New), $Pages, $Duration, $Rate) +} + +function Write-DlpSummary { + <# + .SYNOPSIS + Final summary card: + + ╭─ 🎉 Report ready ─────────────────────────────────────────────────╮ + 📅 Period ... + ✉️ Messages ... + ╰────────────────────────────────────────────────────────────────────╯ + .PARAMETER Values + Ordered list: key = label, value = @(IconName, Text) or plain text. + #> + param([Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][System.Collections.Specialized.OrderedDictionary]$Values, [ValidateSet('Ok', 'Warn', 'Fail')][string]$Status = 'Ok') + $C = $script:C; $F = Get-DlpFrame; $width = 74 + $color = @{ Ok = $C.Green; Warn = $C.Yellow; Fail = $C.Red }[$Status] + $icon = $script:Icons[@{ Ok = 'Done'; Warn = 'Warn'; Fail = 'Fail' }[$Status]] + $iconWidth = if ($script:IconStyle -eq 'Emoji') { 2 } else { 1 } + $head = " $icon $Title " + $rest = [Math]::Max(2, $width - 1 - ($head.Length - $icon.Length + $iconWidth)) + Write-Host '' + Write-Host (" {0}{1}{2}{3}{4}{0}{5}{6}{7}" -f $color, $F.TopLeft, $F.Horizontal, $C.Bold, $head, ($C.Reset + $color), ([string]::new($F.Horizontal, $rest) + $F.TopRight), $C.Reset) + foreach ($key in $Values.Keys) { + $value = $Values[$key] + $rowIcon, $text = if ($value -is [array]) { (Get-DlpIcon $value[0]), $value[1] } else { ' ', $value } + Write-Host (" {0}{1}{2,-10}{3} {4}" -f $rowIcon, $C.Dim, $key, $C.Reset, $text) + Write-DlpLog 'INFO' ("Summary - {0}: {1}" -f $key, $text) + } + Write-Host (" {0}{1}{2}{3}{4}" -f $color, $F.BottomLeft, [string]::new($F.Horizontal, $width), $F.BottomRight, $C.Reset) + Write-Host '' +} + +#endregion +#region 2. Configuration ------------------------------------------------------------------ + +function Resolve-DlpPath { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Root) + $expanded = [Environment]::ExpandEnvironmentVariables($Path) + if ([IO.Path]::IsPathRooted($expanded)) { return [IO.Path]::GetFullPath($expanded) } + return [IO.Path]::GetFullPath((Join-Path $Root $expanded)) +} + +function Import-DlpConfiguration { + <# + .SYNOPSIS + Reads the configuration file, checks every value and returns it with absolute paths. + All problems are reported together so the administrator can fix them in one go. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path, [string]$Root = $script:ToolRoot) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Configuration file not found: $Path" } + try { $config = Import-PowerShellDataFile -LiteralPath $Path } + catch { throw "The configuration file is not valid PowerShell data ($Path): $($_.Exception.Message)" } + + $errors = [Collections.Generic.List[string]]::new() + $guid = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' + foreach ($section in 'Tenant', 'Target', 'Authentication', 'Collection', 'Storage', 'Report', 'Logging') { + if (-not $config.ContainsKey($section) -or $config[$section] -isnot [hashtable]) { $errors.Add("Section '$section' is missing.") } + } + if ($errors.Count) { throw ("Invalid configuration ($Path):`n - " + ($errors -join "`n - ")) } + + function Get-Value([hashtable]$Section, [string]$SectionName, [string]$Key, $Default, [switch]$Required) { + if ($Section.ContainsKey($Key) -and $null -ne $Section[$Key] -and "$($Section[$Key])" -ne '') { return $Section[$Key] } + if ($Required) { $errors.Add("$SectionName.$Key is required.") } + return $Default + } + function Test-Int($Value, [string]$Name, [long]$Min, [long]$Max) { + $n = 0L + if (-not [long]::TryParse("$Value", [ref]$n) -or $n -lt $Min -or $n -gt $Max) { $errors.Add("$Name must be a whole number between $Min and $Max (current value: '$Value').") } + return [int]$n + } + function Test-Bool($Value, [string]$Name) { + if ($Value -isnot [bool]) { $errors.Add("$Name must be `$true or `$false (current value: '$Value').") ; return $false } + return $Value + } + + $t = $config.Tenant; $g = $config.Target; $a = $config.Authentication; $c = $config.Collection; $s = $config.Storage; $r = $config.Report; $l = $config.Logging + $settings = [ordered]@{ + ConfigPath = [IO.Path]::GetFullPath($Path) + Tenant = [ordered]@{ + TenantId = [string](Get-Value $t 'Tenant' 'TenantId' '' -Required) + Organization = [string](Get-Value $t 'Tenant' 'Organization' '') + } + Target = [ordered]@{ + PolicyId = [string](Get-Value $g 'Target' 'PolicyId' '' -Required) + RuleId = [string](Get-Value $g 'Target' 'RuleId' '' -Required) + PolicyName = [string](Get-Value $g 'Target' 'PolicyName' '') + RuleName = [string](Get-Value $g 'Target' 'RuleName' '') + } + Authentication = [ordered]@{ + Mode = [string](Get-Value $a 'Authentication' 'Mode' 'Interactive') + UserPrincipalName = [string](Get-Value $a 'Authentication' 'UserPrincipalName' '') + DisableWAM = Get-Value $a 'Authentication' 'DisableWAM' $true + AppId = [string](Get-Value $a 'Authentication' 'AppId' '') + CertificateThumbprint = [string](Get-Value $a 'Authentication' 'CertificateThumbprint' '') + } + Collection = [ordered]@{ + PageSize = Test-Int (Get-Value $c 'Collection' 'PageSize' 1000) 'Collection.PageSize' 1 5000 + SliceHours = Test-Int (Get-Value $c 'Collection' 'SliceHours' 24) 'Collection.SliceHours' 1 24 + SettlingHours = Test-Int (Get-Value $c 'Collection' 'SettlingHours' 6) 'Collection.SettlingHours' 0 72 + BackfillDays = Test-Int (Get-Value $c 'Collection' 'BackfillDays' 30) 'Collection.BackfillDays' 1 30 + SourceRetentionDays = Test-Int (Get-Value $c 'Collection' 'SourceRetentionDays' 30) 'Collection.SourceRetentionDays' 1 30 + RetentionWarningDays = Test-Int (Get-Value $c 'Collection' 'RetentionWarningDays' 7) 'Collection.RetentionWarningDays' 0 364 + MaxSliceRestarts = Test-Int (Get-Value $c 'Collection' 'MaxSliceRestarts' 2) 'Collection.MaxSliceRestarts' 0 5 + MinimumSliceMinutes = Test-Int (Get-Value $c 'Collection' 'MinimumSliceMinutes' 15) 'Collection.MinimumSliceMinutes' 1 720 + MaxPagesPerSlice = Test-Int (Get-Value $c 'Collection' 'MaxPagesPerSlice' 2000) 'Collection.MaxPagesPerSlice' 1 100000 + CookieSafetySeconds = Test-Int (Get-Value $c 'Collection' 'CookieSafetySeconds' 105) 'Collection.CookieSafetySeconds' 30 119 + } + Storage = [ordered]@{ + DatabasePath = Resolve-DlpPath ([string](Get-Value $s 'Storage' 'DatabasePath' '.\data\PurviewDlpReport.sqlite')) $Root + RetentionDays = Test-Int (Get-Value $s 'Storage' 'RetentionDays' 180) 'Storage.RetentionDays' 0 3650 + KeepQuarantinedRecords = Test-Bool (Get-Value $s 'Storage' 'KeepQuarantinedRecords' $true) 'Storage.KeepQuarantinedRecords' + } + Report = [ordered]@{ + DefaultRange = [string](Get-Value $r 'Report' 'DefaultRange' 'Last7Days') + TimeZone = [string](Get-Value $r 'Report' 'TimeZone' 'Europe/Paris') + OutputPath = Resolve-DlpPath ([string](Get-Value $r 'Report' 'OutputPath' '.\reports')) $Root + FilePrefix = [string](Get-Value $r 'Report' 'FilePrefix' 'PurviewDLP') + Formats = @(Get-Value $r 'Report' 'Formats' @('Csv', 'Html')) + IncludeRecipientDetails = Test-Bool (Get-Value $r 'Report' 'IncludeRecipientDetails' $true) 'Report.IncludeRecipientDetails' + SplitBy = [string](Get-Value $r 'Report' 'SplitBy' 'Week') + MaxRowsPerFile = Test-Int (Get-Value $r 'Report' 'MaxRowsPerFile' 500000) 'Report.MaxRowsPerFile' 1000 1048575 + CsvDelimiter = [string](Get-Value $r 'Report' 'CsvDelimiter' ';') + Title = [string](Get-Value $r 'Report' 'Title' 'DLP messages with more than 25 recipients') + CompanyName = [string](Get-Value $r 'Report' 'CompanyName' '') + ShowTenantInformation = Test-Bool (Get-Value $r 'Report' 'ShowTenantInformation' $false) 'Report.ShowTenantInformation' + EnvironmentName = [string](Get-Value $r 'Report' 'EnvironmentName' '') + TemplatePath = Join-Path $Root 'templates\Report.template.html' + } + Logging = [ordered]@{ + Path = Resolve-DlpPath ([string](Get-Value $l 'Logging' 'Path' '.\logs')) $Root + RetentionDays = Test-Int (Get-Value $l 'Logging' 'RetentionDays' 30) 'Logging.RetentionDays' 1 3650 + } + } + if ($settings.Tenant.TenantId -and $settings.Tenant.TenantId -notmatch $guid) { $errors.Add('Tenant.TenantId must be a GUID.') } + if ($settings.Target.PolicyId -and $settings.Target.PolicyId -notmatch $guid) { $errors.Add('Target.PolicyId must be a GUID.') } + if ($settings.Target.RuleId -and $settings.Target.RuleId -notmatch $guid) { $errors.Add('Target.RuleId must be a GUID.') } + if ($settings.Authentication.Mode -notin 'Interactive', 'Certificate') { $errors.Add("Authentication.Mode must be 'Interactive' or 'Certificate'.") } + [void](Test-Bool $settings.Authentication.DisableWAM 'Authentication.DisableWAM') + if ($settings.Authentication.Mode -eq 'Certificate') { + if ($settings.Authentication.AppId -notmatch $guid) { $errors.Add('Authentication.AppId must be the application (client) ID (GUID) in Certificate mode.') } + if ($settings.Authentication.CertificateThumbprint -notmatch '^[0-9a-fA-F]{40}$') { $errors.Add('Authentication.CertificateThumbprint must be a 40-character thumbprint in Certificate mode.') } + if ($settings.Tenant.Organization -notmatch '\.onmicrosoft\.com$') { $errors.Add('Tenant.Organization must be the initial domain (xxx.onmicrosoft.com) in Certificate mode.') } + } + if ($settings.Collection.RetentionWarningDays -ge $settings.Collection.SourceRetentionDays) { $errors.Add('Collection.RetentionWarningDays must be lower than Collection.SourceRetentionDays.') } + if ($settings.Collection.SettlingHours -ge ($settings.Collection.SourceRetentionDays * 24)) { $errors.Add('Collection.SettlingHours must be lower than the source retention period.') } + if ($settings.Storage.RetentionDays -gt 0 -and $settings.Storage.RetentionDays -lt $settings.Collection.BackfillDays) { $errors.Add('Storage.RetentionDays must be 0 or at least Collection.BackfillDays to avoid recollecting purged history.') } + if ($settings.Report.DefaultRange -notin 'Last24Hours', 'Last7Days', 'Last30Days', 'PreviousMonth') { $errors.Add('Report.DefaultRange must be Last24Hours, Last7Days, Last30Days or PreviousMonth.') } + if ($settings.Report.SplitBy -notin 'Rows', 'Day', 'Week') { $errors.Add("Report.SplitBy must be 'Rows', 'Day' or 'Week'.") } + if (-not $settings.Report.Formats.Count -or @($settings.Report.Formats | Where-Object { $_ -notin 'Csv', 'Html' }).Count) { $errors.Add("Report.Formats must contain 'Csv', 'Html' or both.") } + if ($settings.Report.CsvDelimiter -notin ';', ',', "`t", '|') { $errors.Add("Report.CsvDelimiter must be ';', ',', '|' or a tab.") } + if (-not $settings.Report.FilePrefix -or $settings.Report.FilePrefix.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) { $errors.Add('Report.FilePrefix must be a valid file name part.') } + if ($r.ContainsKey('Author') -or $r.ContainsKey('AuthorName')) { $errors.Add('Report author is fixed to Nicolas Fabert. Remove Report.Author / Report.AuthorName; use Report.CompanyName for company branding.') } + try { $settings['Zone'] = Get-DlpTimeZone $settings.Report.TimeZone } catch { $errors.Add($_.Exception.Message) } + if ($errors.Count) { throw ("Invalid configuration ($Path):`n - " + ($errors -join "`n - ")) } + return $settings +} + +#endregion + +#region 3. Engine (SQLite + compiled C#) ------------------------------------------------------ + +function Initialize-DlpEngine { + <# + .SYNOPSIS + Verifies the package, then loads SQLite and compiles the authenticated C# + source in memory once per process. Never loads the unsigned bin cache. + #> + [CmdletBinding()] + param([string]$Root = $script:ToolRoot) + if (-not [string]::Equals([IO.Path]::GetFullPath($Root).TrimEnd('\'), + [IO.Path]::GetFullPath($script:ToolRoot).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { + throw 'Package integrity: engine root must be the imported package root.' + } + Assert-DlpPackageIntegrity + if ('PurviewDlpReport.DlpStore' -as [type]) { return } + $lib = Join-Path $Root 'lib\sqlite' + $arch = if ([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq 'Arm64') { 'win-arm64' } else { 'win-x64' } + $native = Join-Path $lib "runtimes\$arch\e_sqlite3.dll" + if (-not (Test-Path -LiteralPath $native)) { throw "SQLite native library not found: $native" } + [void][Runtime.InteropServices.NativeLibrary]::Load($native) + foreach ($name in 'SQLitePCLRaw.core', 'SQLitePCLRaw.provider.e_sqlite3', 'SQLitePCLRaw.batteries_v2', 'Microsoft.Data.Sqlite') { + Add-Type -LiteralPath (Join-Path $lib "$name.dll") + } + [SQLitePCL.Batteries_V2]::Init() + + $source = Join-Path $Root 'src\PurviewDlpReport.Engine.cs' + $references = @( + (Join-Path $lib 'Microsoft.Data.Sqlite.dll'), 'System.IO.Compression', 'System.Text.Json', 'System.Text.Encodings.Web', + 'System.Data.Common', 'System.Linq', 'System.Collections', 'System.Text.RegularExpressions', 'System.Security.Cryptography', + 'System.Runtime', 'System.Memory', 'System.ComponentModel.Primitives', 'System.ComponentModel', 'System.Transactions.Local', + 'System.Text.Encoding.Extensions', 'System.Runtime.Extensions', 'System.IO', 'System.Runtime.InteropServices', 'System.Console', 'netstandard') + Add-Type -LiteralPath $source -ReferencedAssemblies $references -IgnoreWarnings -WarningAction SilentlyContinue +} + +function Open-DlpStore { + param([Parameter(Mandatory)]$Settings, [switch]$ReadOnly) + $path = $Settings.Storage.DatabasePath + if ($ReadOnly -and -not (Test-Path -LiteralPath $path)) { throw "The database does not exist yet: $path. Run a collection first (-Mode Collect)." } + $store = [PurviewDlpReport.DlpStore]::new($path, [bool]$ReadOnly, $script:ToolVersion) + try { + $store.ValidateTenant($Settings.Tenant.TenantId) + $store.KeepQuarantinedRaw = [bool]$Settings.Storage.KeepQuarantinedRecords + } catch { + $store.Dispose() + throw + } + return $store +} + +#endregion + +#region 4. Periods and coverage ------------------------------------------------------------ + +function Get-DlpTimeZone { + <# Accepts an IANA name (Europe/Paris) or a Windows name (Romance Standard Time). #> + param([Parameter(Mandatory)][string]$Id) + try { return [TimeZoneInfo]::FindSystemTimeZoneById($Id) } catch { } + $windowsId = $null + if ([TimeZoneInfo]::TryConvertIanaIdToWindowsId($Id, [ref]$windowsId)) { try { return [TimeZoneInfo]::FindSystemTimeZoneById($windowsId) } catch { } } + $known = @{ 'Europe/Paris' = 'Romance Standard Time'; 'Europe/Brussels' = 'Romance Standard Time'; 'Europe/London' = 'GMT Standard Time'; 'UTC' = 'UTC' } + if ($known.ContainsKey($Id)) { return [TimeZoneInfo]::FindSystemTimeZoneById($known[$Id]) } + throw "Unknown time zone '$Id' (Report.TimeZone). Use a name such as 'Europe/Paris' or 'Romance Standard Time'." +} + +function ConvertTo-DlpUnixMs { + <# Text date -> Unix ms. Without an explicit offset (Z, +02:00) the date is read in the report time zone. #> + param([Parameter(Mandatory)][string]$Text, [Parameter(Mandatory)][TimeZoneInfo]$Zone) + $culture = [Globalization.CultureInfo]::InvariantCulture + if ($Text -match '(Z|[+-]\d{2}:?\d{2})$') { + return [DateTimeOffset]::Parse($Text, $culture).ToUnixTimeMilliseconds() + } + $formats = [string[]]@('yyyy-MM-dd', 'yyyy-MM-dd HH:mm', 'yyyy-MM-ddTHH:mm', 'yyyy-MM-dd HH:mm:ss', 'yyyy-MM-ddTHH:mm:ss') + $local = [datetime]::MinValue + if (-not [datetime]::TryParseExact($Text, $formats, $culture, [Globalization.DateTimeStyles]::None, [ref]$local)) { + throw "Invalid date '$Text'. Use yyyy-MM-dd, 'yyyy-MM-dd HH:mm' or an ISO 8601 value with an offset." + } + return [PurviewDlpReport.Coverage]::LocalToUnixMs($local, $Zone) +} + +function Resolve-DlpPeriod { + <# + .SYNOPSIS + Converts a range name into a [start, end) period in Unix milliseconds. + .DESCRIPTION + Last24Hours / Last7Days / Last30Days : rolling windows ending now. + PreviousMonth : the previous calendar month in the report time zone. + Month (-Month yyyy-MM), Day (-Date yyyy-MM-dd), Custom (-Start / -End). + The end is never later than now. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('Last24Hours', 'Last7Days', 'Last30Days', 'PreviousMonth', 'Month', 'Day', 'Custom')][string]$Range, + [string]$Month, [string]$Date, [string]$Start, [string]$End, + [Parameter(Mandatory)][TimeZoneInfo]$Zone, + [DateTimeOffset]$Now = [DateTimeOffset]::UtcNow + ) + $culture = [Globalization.CultureInfo]::InvariantCulture + $nowMs = $Now.ToUnixTimeMilliseconds(); $nowMs -= $nowMs % 1000 + $day = 86400000L + switch ($Range) { + 'Last24Hours' { $s = $nowMs - $day; $e = $nowMs } + 'Last7Days' { $s = $nowMs - 7 * $day; $e = $nowMs } + 'Last30Days' { $s = $nowMs - 30 * $day; $e = $nowMs } + 'PreviousMonth' { + $local = [TimeZoneInfo]::ConvertTime($Now, $Zone).DateTime + $first = [datetime]::new($local.Year, $local.Month, 1).AddMonths(-1) + $s = [PurviewDlpReport.Coverage]::LocalToUnixMs($first, $Zone) + $e = [PurviewDlpReport.Coverage]::LocalToUnixMs($first.AddMonths(1), $Zone) + } + 'Month' { + $first = [datetime]::MinValue + if (-not $Month -or -not [datetime]::TryParseExact($Month, 'yyyy-MM', $culture, 'None', [ref]$first)) { throw "-Range Month requires -Month in the format yyyy-MM (for example -Month 2026-08)." } + $s = [PurviewDlpReport.Coverage]::LocalToUnixMs($first, $Zone) + $e = [PurviewDlpReport.Coverage]::LocalToUnixMs($first.AddMonths(1), $Zone) + } + 'Day' { + $d = [datetime]::MinValue + if (-not $Date -or -not [datetime]::TryParseExact($Date, 'yyyy-MM-dd', $culture, 'None', [ref]$d)) { throw "-Range Day requires -Date in the format yyyy-MM-dd." } + $s = [PurviewDlpReport.Coverage]::LocalToUnixMs($d, $Zone) + $e = [PurviewDlpReport.Coverage]::LocalToUnixMs($d.AddDays(1), $Zone) + } + 'Custom' { + if (-not $Start -or -not $End) { throw '-Range Custom requires -Start and -End.' } + $s = ConvertTo-DlpUnixMs $Start $Zone + $e = ConvertTo-DlpUnixMs $End $Zone + } + } + if ($e -gt $nowMs) { $e = $nowMs } + if ($e -le $s) { throw "The requested period is empty or in the future ($Range)." } + [pscustomobject]@{ + Range = $Range + StartMs = [long]$s + EndMs = [long]$e + Label = [PurviewDlpReport.ReportPlanner]::PeriodLabel($s, $e, $Zone) + } +} + +function Get-DlpRangeLength { + <# Total length in milliseconds of a list of ranges (each with Start and End). #> + param([AllowNull()][AllowEmptyCollection()]$Ranges) + $sum = 0L + foreach ($r in @($Ranges)) { if ($null -ne $r) { $sum += [long]$r.End - [long]$r.Start } } + return $sum +} + +function New-DlpRangeList { + param([Parameter()][AllowEmptyCollection()][object[]]$Ranges) + $list = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() + foreach ($r in $Ranges) { if ($null -ne $r) { $list.Add([PurviewDlpReport.TimeRange]::new([long]$r.Start, [long]$r.End)) } } + return , $list +} + +function Get-DlpCollectionPlan { + <# + .SYNOPSIS + Decides what must be collected for a period. + .DESCRIPTION + - Ranges already collected and "settled" (collected at least SettlingHours after + their end) are skipped. + - Missing ranges younger than the Activity Explorer retention are collected, + cut into slices that never cross local midnight. + - Missing ranges older than the retention are reported as unrecoverable. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, + [Parameter(Mandatory)][long]$StartMs, [Parameter(Mandatory)][long]$EndMs, + [Parameter(Mandatory)]$Settings, [long]$NowMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + ) + $c = $Settings.Collection + $settled = $Store.GetCoveredRanges($TargetId, [long]$c.SettlingHours * 3600000) + $missing = [PurviewDlpReport.Coverage]::Gaps($settled, $StartMs, $EndMs) + # One hour of margin: the oldest hour may already be purged by the time it is requested. + $earliest = $NowMs - [long]$c.SourceRetentionDays * 86400000 + 3600000 + $collectable = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() + $lost = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() + foreach ($gap in $missing) { + if ($gap.End -le $earliest) { $lost.Add($gap); continue } + if ($gap.Start -lt $earliest) { + $lost.Add([PurviewDlpReport.TimeRange]::new($gap.Start, $earliest)) + $collectable.Add([PurviewDlpReport.TimeRange]::new($earliest, $gap.End)) + } else { $collectable.Add($gap) } + } + $slices = [PurviewDlpReport.Coverage]::SplitIntoSlices($collectable, $Settings.Zone, $c.SliceHours) + # Part of the collectable time never collected before (the rest is a refresh of recent data). + $coveredAny = $Store.GetCoveredRanges($TargetId, 0) + $neverCollected = 0L + foreach ($range in $collectable) { $neverCollected += Get-DlpRangeLength ([PurviewDlpReport.Coverage]::Gaps($coveredAny, $range.Start, $range.End)) } + [pscustomobject]@{ + Missing = $missing + Collectable = $collectable + Unrecoverable = $lost + Slices = $slices + CollectableMs = Get-DlpRangeLength $collectable + NewMs = $neverCollected + RefreshMs = (Get-DlpRangeLength $collectable) - $neverCollected + UnrecoverableMs = Get-DlpRangeLength $lost + EarliestSourceMs = $earliest + } +} + +function Get-DlpCoverage { + <# Share of a period present in the database, with optional settled coverage. #> + param( + [Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, + [Parameter(Mandatory)][long]$StartMs, [Parameter(Mandatory)][long]$EndMs, + [long]$SettlingMs = 0 + ) + $currentCovered = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() + foreach ($range in @($Store.GetCoveredRanges($TargetId, 0))) { + $currentCovered.Add([PurviewDlpReport.TimeRange]::new([long]$range.Start, [long]$range.End)) + } + $settledCovered = $currentCovered + if ($SettlingMs -gt 0) { + $settledCovered = [Collections.Generic.List[PurviewDlpReport.TimeRange]]::new() + foreach ($range in @($Store.GetCoveredRanges($TargetId, $SettlingMs))) { + $settledCovered.Add([PurviewDlpReport.TimeRange]::new([long]$range.Start, [long]$range.End)) + } + } + $currentGaps = [PurviewDlpReport.Coverage]::Gaps($currentCovered, $StartMs, $EndMs) + $settledGaps = [PurviewDlpReport.Coverage]::Gaps($settledCovered, $StartMs, $EndMs) + $currentMissingMs = Get-DlpRangeLength $currentGaps + $settledMissingMs = Get-DlpRangeLength $settledGaps + [pscustomobject]@{ + Percent = if ($EndMs -gt $StartMs) { [Math]::Round(100.0 * ($EndMs - $StartMs - $currentMissingMs) / ($EndMs - $StartMs), 2) } else { 100 } + Gaps = $currentGaps + MissingMs = $currentMissingMs + FinalPercent = if ($EndMs -gt $StartMs) { [Math]::Round(100.0 * ($EndMs - $StartMs - $settledMissingMs) / ($EndMs - $StartMs), 2) } else { 100 } + FinalGaps = $settledGaps + ProvisionalMs = [Math]::Max(0, $settledMissingMs - $currentMissingMs) + Complete = $currentMissingMs -eq 0 + } +} + +function Get-DlpExpiringGaps { + <# Missing ranges that Activity Explorer will no longer return within RetentionWarningDays. #> + param([Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, [Parameter(Mandatory)]$Settings, [long]$NowMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + $c = $Settings.Collection + $stats = $Store.GetStatistics($TargetId) + if ($null -eq $stats.FirstCoveredMs) { return @() } + $windowStart = [Math]::Max([long]$stats.FirstCoveredMs, $NowMs - [long]$c.SourceRetentionDays * 86400000) + $gaps = [PurviewDlpReport.Coverage]::Gaps($Store.GetCoveredRanges($TargetId, 0), $windowStart, $NowMs - [long]$c.SettlingHours * 3600000) + $limit = $NowMs - [long]($c.SourceRetentionDays - $c.RetentionWarningDays) * 86400000 + foreach ($gap in $gaps) { + if ($gap.Start -lt $limit) { + [pscustomobject]@{ Start = $gap.Start; End = $gap.End; DaysLeft = [Math]::Max(0, [Math]::Floor(($gap.Start - ($NowMs - [long]$c.SourceRetentionDays * 86400000)) / 86400000.0)) } + } + } +} + +#endregion + +#region 5. Connection ------------------------------------------------------------------------ + +# ExchangeOnlineManagement versions that must not be used, by authentication mode (checked in the lab). +$script:ExoKnownIssues = @{ + '3.10.0' = @{ Modes = @('Certificate'); Issue = 'its certificate authentication fails with "Object reference not set to an instance of an object" (fixed in 3.10.1)' } +} + +function Select-DlpExoModule { + <# + .SYNOPSIS + Chooses the ExchangeOnlineManagement module to use: the version already loaded in the session, + otherwise the most recent installed version, 3.9.0 or later, without a known issue for the mode. + Returns the module and the versions skipped; throws a message with the command to run otherwise. + #> + param([AllowEmptyCollection()][object[]]$Available, [AllowNull()]$Loaded, [Parameter(Mandatory)][string]$Mode) + $install = 'Install-Module ExchangeOnlineManagement -MinimumVersion 3.10.1 -Scope CurrentUser -Force' + $problem = { + param($Version) + $known = $script:ExoKnownIssues[$Version.ToString()] + if ($Version -lt [version]'3.9.0') { return 'it is older than 3.9.0' } + if ($known -and $Mode -in $known.Modes) { return $known.Issue } + } + if ($Loaded) { + $why = & $problem $Loaded.Version + if ($why) { throw "ExchangeOnlineManagement $($Loaded.Version) is already loaded in this PowerShell session and cannot be used: $why. Open a new PowerShell window. If this version is loaded automatically, run: $install" } + return [pscustomobject]@{ Module = $Loaded; Skipped = @() } + } + $sorted = @($Available | Where-Object { $_ } | Sort-Object Version -Descending) + if (-not $sorted.Count) { throw "The ExchangeOnlineManagement module is not installed. Run: $install" } + $skipped = [Collections.Generic.List[string]]::new() + foreach ($m in $sorted) { + $why = & $problem $m.Version + if (-not $why) { return [pscustomobject]@{ Module = $m; Skipped = @($skipped) } } + if (-not $skipped.Contains("$($m.Version): $why")) { $skipped.Add("$($m.Version): $why") } + } + throw "No usable ExchangeOnlineManagement version is installed (3.9.2 and 3.10.1 validated). $(@($skipped) -join '; '). Run: $install" +} + +function Connect-DlpActivityExplorer { + <# + .SYNOPSIS + Connects to Security & Compliance PowerShell and returns the function used to call + Export-ActivityExplorerData. The tenant (and the account in interactive mode) is checked. + #> + [CmdletBinding()] + param([Parameter(Mandatory)]$Settings) + $auth = $Settings.Authentication + $choice = Select-DlpExoModule -Available @(Get-Module ExchangeOnlineManagement -ListAvailable) -Loaded (Get-Module ExchangeOnlineManagement | Select-Object -First 1) -Mode $auth.Mode + foreach ($s in $choice.Skipped) { Write-DlpItem Info "ExchangeOnlineManagement $s - skipped, $($choice.Module.Version) used." } + $module = $choice.Module + if (-not (Get-Module ExchangeOnlineManagement)) { Import-Module $module -Global -WarningAction SilentlyContinue } + Write-DlpLog 'INFO' "ExchangeOnlineManagement $($module.Version) ($($module.ModuleBase))" + + $prefix = $script:CommandPrefix + $existing = @(Get-ConnectionInformation -ErrorAction SilentlyContinue | Where-Object { $_.ModulePrefix -eq $prefix -and $_.State -eq 'Connected' }) + $connectedHere = $false + if (-not $existing) { + $parameters = @{ Prefix = $prefix; ShowBanner = $false; ErrorAction = 'Stop' } + if ($auth.Mode -eq 'Certificate') { + $parameters.AppId = $auth.AppId + $parameters.CertificateThumbprint = $auth.CertificateThumbprint + $parameters.Organization = $Settings.Tenant.Organization + } else { + if ($auth.UserPrincipalName) { $parameters.UserPrincipalName = $auth.UserPrincipalName } + if ($auth.DisableWAM -and (Get-Command Connect-IPPSSession).Parameters.ContainsKey('DisableWAM')) { $parameters.DisableWAM = $true } + } + Connect-IPPSSession @parameters | Out-Null + $connectedHere = $true + } + $connection = @(Get-ConnectionInformation | Where-Object { $_.ModulePrefix -eq $prefix -and $_.State -eq 'Connected' }) | Select-Object -Last 1 + if (-not $connection) { throw 'The Security & Compliance connection was not established.' } + $account = if ($auth.Mode -eq 'Certificate') { "app $($auth.AppId)" } else { [string]$connection.UserPrincipalName } + $problem = $null + if ($Settings.Tenant.TenantId -and [string]$connection.TenantID -ne $Settings.Tenant.TenantId) { $problem = "Connected to tenant $($connection.TenantID), but the configuration expects $($Settings.Tenant.TenantId)." } + elseif ($auth.Mode -eq 'Interactive' -and $auth.UserPrincipalName -and $account -ine $auth.UserPrincipalName) { $problem = "Signed in as $account, but the configuration expects $($auth.UserPrincipalName)." } + if ($problem) { + if ($connectedHere) { Disconnect-ExchangeOnline -ConnectionId $connection.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue } + throw $problem + } + $commandName = "Export-$($prefix)ActivityExplorerData" + $command = Get-Command $commandName -ErrorAction SilentlyContinue + if (-not $command) { + throw "Export-ActivityExplorerData is not available for $account. The account (or application) needs a Purview role that can export Activity Explorer data - see the guide, chapter 4 (accounts) and Annex F (application)." + } + $exporter = { param([hashtable]$Query) & $command @Query -ErrorAction Stop }.GetNewClosure() + [pscustomobject]@{ + Exporter = $exporter + Account = $account + TenantId = [string]$connection.TenantID + Mode = $auth.Mode + ConnectionId = $connection.ConnectionId + ConnectedHere = $connectedHere + } +} + +function Disconnect-DlpActivityExplorer { + param($Connection) + if ($Connection -and $Connection.ConnectedHere -and $Connection.ConnectionId) { + Disconnect-ExchangeOnline -ConnectionId $Connection.ConnectionId -Confirm:$false -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } +} + +#endregion + +#region 6. Collection --------------------------------------------------------------------------- + +function Get-DlpProperty { + param($Object, [string]$Name) + if ($null -eq $Object) { return $null } + if ($Object -is [Collections.IDictionary]) { if ($Object.Contains($Name)) { return $Object[$Name] }; return $null } + $p = $Object.PSObject.Properties[$Name] + if ($p) { return $p.Value } + return $null +} + +function Test-DlpPageEnvelope { + <# + .SYNOPSIS + Checks one response of Export-ActivityExplorerData before its data is used. + .NOTES + Known false positive: the service fills ErrorData with the type name + "Microsoft.Exchange.Hygiene.DataInsights.Common.DataInsightsErrorData" even when + ResultCode is Success. That value is accepted only together with ResultCode = Success. + #> + param([Parameter(Mandatory)]$Response) + $resultCode = [string](Get-DlpProperty $Response 'ResultCode') + foreach ($field in 'Exception', 'ErrorData') { + $value = Get-DlpProperty $Response $field + if ($null -eq $value -or ($value -is [bool] -and -not $value)) { continue } + $text = ([string]$value).Trim() + if ($text -in '', '[]', '{}', 'null') { continue } + if ($field -eq 'ErrorData' -and $text -eq 'Microsoft.Exchange.Hygiene.DataInsights.Common.DataInsightsErrorData' -and $resultCode -eq 'Success') { continue } + throw "SourceEnvelopeError: $field = $text (ResultCode = $resultCode)." + } + $lastValue = Get-DlpProperty $Response 'LastPage' + if ($lastValue -is [bool]) { $last = $lastValue } + elseif ("$lastValue" -match '^(?i:true|false)$') { $last = [bool]::Parse("$lastValue") } + else { throw 'MalformedPage: LastPage is missing or is not a boolean.' } + $data = Get-DlpProperty $Response 'ResultData' + # Only .Length is used on ResultData here: passing a large string to a .NET method is slow (AMSI scan). + if ($data -isnot [string] -or $data.Length -eq 0) { throw 'MalformedPage: ResultData is missing (an empty page must be the JSON text []).' } + $recordCount = $null + $reported = Get-DlpProperty $Response 'RecordCount' + if ($null -ne $reported) { + $n = 0L + if (-not [long]::TryParse("$reported", [ref]$n) -or $n -lt 0) { throw 'MalformedPage: RecordCount is not a number.' } + $recordCount = $n + } + $total = $null + $reportedTotal = Get-DlpProperty $Response 'TotalResultCount' + if ($null -ne $reportedTotal) { $n = 0L; if ([long]::TryParse("$reportedTotal", [ref]$n)) { $total = $n } } + [pscustomobject]@{ + LastPage = $last + Watermark = [string](Get-DlpProperty $Response 'Watermark') + ResultData = $data + RecordCount = $recordCount + TotalResultCount = $total + ResultCode = $resultCode + } +} + +function Invoke-DlpCollection { + <# + .SYNOPSIS + Collects Activity Explorer events for a list of time slices and stores them in SQLite. + .DESCRIPTION + For each slice, pages are requested with the same filters (Exchange, DLPRuleMatch, + policy, rule) until LastPage = true. Every page is stored immediately, so an + interrupted execution never loses what was already received; the slice is marked + "Completed" only when its last page has been stored. + + On a recoverable error the slice is retried from its beginning (the paging cookie + cannot be reused after 120 s), then split in two halves. A permanent error + (permissions, command not found) stops the collection. + .OUTPUTS + An object with the counters and the final state (Completed / Error). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Store, + [Parameter(Mandatory)][long]$RunId, + [Parameter(Mandatory)][long]$TargetId, + [Parameter(Mandatory)]$Settings, + [Parameter(Mandatory)][AllowEmptyCollection()]$Slices, + [Parameter(Mandatory)][scriptblock]$Exporter + ) + $c = $Settings.Collection + $zone = $Settings.Zone + $stats = [ordered]@{ + Completed = $false; Error = $null; Slices = @($Slices).Count; SlicesDone = 0; Pages = 0; Records = 0; NewEvents = 0 + Duplicates = 0; Quarantined = 0; Conflicts = 0; WithoutCount = 0; OutOfRange = 0; Restarts = 0; Subdivisions = 0 + SourceSeconds = 0.0; EngineSeconds = 0.0; Seconds = 0.0; ServiceAnnounced = 0; OutOfRangeHappenedMs = [Collections.Generic.List[long]]::new(); QuarantineReasons = @{} + BoundaryEvents = 0; BoundaryNewEvents = 0; BoundaryDuplicates = 0; UnexpectedOutOfRange = 0 + UnexpectedTimestampSamples = [Collections.Generic.List[object]]::new() + } + $pending = [Collections.Generic.LinkedList[object]]::new() + foreach ($slice in $Slices) { [void]$pending.AddLast([pscustomobject]@{ Start = [long]$slice.Start; End = [long]$slice.End; Restart = 0 }) } + $totalSpan = [double](Get-DlpRangeLength $Slices) + $doneSpan = 0.0 + $clock = [Diagnostics.Stopwatch]::StartNew() + $pageData = [PurviewDlpReport.PageData]::new() + $permanent = '(?i)(not recognized|CommandNotFound|ParameterBinding|access.{0,20}denied|forbidden|\b403\b|not authori[sz]ed|insufficient|does not have permission)' + $index = 0 + if ($pending.Count) { Write-DlpTableRow -Header } + try { + while ($pending.Count) { + $slice = $pending.First.Value; $pending.RemoveFirst() + $index++ + $label = Format-DlpRange $slice.Start $slice.End $zone + $intervalId = $Store.BeginInterval($RunId, $TargetId, $slice.Start, $slice.End) + $pages = 0; $records = 0L; $new = 0L; $serviceTotal = $null; $cookie = $null + $cookies = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $hashes = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $sinceLastPage = $null + $sliceClock = [Diagnostics.Stopwatch]::StartNew() + Write-DlpLog 'INFO' "Slice $label started (interval $intervalId, attempt $($slice.Restart + 1))" + try { + while ($true) { + if ($pages -ge $c.MaxPagesPerSlice) { throw "SlicePageBudgetExceeded: more than $($c.MaxPagesPerSlice) pages for one window." } + if ($sinceLastPage -and $sinceLastPage.Elapsed.TotalSeconds -ge $c.CookieSafetySeconds) { throw 'PageCookieExpiredLocalSafetyBudget: the next page could not be requested within the cookie lifetime.' } + $query = @{ + StartTime = [DateTimeOffset]::FromUnixTimeMilliseconds($slice.Start).UtcDateTime + EndTime = [DateTimeOffset]::FromUnixTimeMilliseconds($slice.End).UtcDateTime + OutputFormat = 'Json' + PageSize = $c.PageSize + Filter1 = @('Workload', 'Exchange') + Filter2 = @('Activity', 'DLPRuleMatch') + Filter3 = @('DLPPolicyId', $Settings.Target.PolicyId) + Filter4 = @('DLPPolicyRuleId', $Settings.Target.RuleId) + } + if ($cookie) { $query.PageCookie = $cookie } + $call = [Diagnostics.Stopwatch]::StartNew() + try { $responses = @(& $Exporter $query) } + finally { $stats.SourceSeconds += $call.Elapsed.TotalSeconds } + $sinceLastPage = [Diagnostics.Stopwatch]::StartNew() + if ($responses.Count -ne 1) { throw "MalformedPage: one response expected, $($responses.Count) received." } + $page = Test-DlpPageEnvelope -Response $responses[0] + $pageData.ResultData = $page.ResultData # property assignment, see PageData in the engine + $result = $Store.IngestPage($intervalId, $TargetId, $Settings.Target.PolicyId, $Settings.Target.RuleId, $pageData) + $pages++ + $stats.EngineSeconds += ($result.ParseMs + $result.NormalizeMs + $result.WriteMs + $result.CommitMs) / 1000 + if ($result.Records -gt $c.PageSize) { throw 'MalformedPage: more records than the requested page size.' } + if ($null -ne $page.RecordCount -and $page.RecordCount -ne $result.Records) { throw "MalformedPage: RecordCount $($page.RecordCount) differs from the $($result.Records) records received." } + if ($result.Records -eq 0 -and -not $page.LastPage) { throw 'EmptyIntermediatePage: an empty page that is not the last one cannot be trusted.' } + if ($result.Records -gt 0 -and -not $hashes.Add($result.Sha256)) { throw 'RepeatedPage: the service returned the same page twice.' } + $records += $result.Records; $new += $result.NewEvents + $stats.Pages++; $stats.Records += $result.Records; $stats.NewEvents += $result.NewEvents; $stats.Duplicates += $result.Duplicates + $stats.Quarantined += $result.Quarantined; $stats.Conflicts += $result.Conflicts; $stats.WithoutCount += $result.WithoutCount; $stats.OutOfRange += $result.OutOfRange + foreach ($happenedMs in $result.OutOfRangeHappenedMs) { + if ($stats.OutOfRangeHappenedMs.Count -ge 10) { break } + $stats.OutOfRangeHappenedMs.Add([long]$happenedMs) + } + $stats.BoundaryEvents += $result.BoundaryEvents + $stats.BoundaryNewEvents += $result.BoundaryNewEvents + $stats.BoundaryDuplicates += $result.BoundaryDuplicates + $stats.UnexpectedOutOfRange += $result.UnexpectedOutOfRange + foreach ($sample in $result.UnexpectedTimestampSamples) { + if ($stats.UnexpectedTimestampSamples.Count -ge 10) { break } + $stats.UnexpectedTimestampSamples.Add($sample) + } + foreach ($reason in $result.QuarantineReasons.Keys) { $stats.QuarantineReasons[$reason] = [int]$stats.QuarantineReasons[$reason] + $result.QuarantineReasons[$reason] } + if ($null -eq $serviceTotal -and $null -ne $page.TotalResultCount) { $serviceTotal = [long]$page.TotalResultCount } + + # Progress: share of the total time span, refined with the announced number of events. + $fraction = if ($serviceTotal -gt 0) { [Math]::Min(1.0, $records / $serviceTotal) } else { 0.0 } + $percent = if ($totalSpan -gt 0) { [Math]::Min(100.0, 100.0 * ($doneSpan + $fraction * ($slice.End - $slice.Start)) / $totalSpan) } else { 100 } + $rate = if ($clock.Elapsed.TotalSeconds -gt 0) { $stats.Records / $clock.Elapsed.TotalSeconds } else { 0 } + $eta = if ($percent -gt 1) { Format-DlpDuration ($clock.Elapsed.TotalSeconds * (100 - $percent) / $percent) } else { 'estimating...' } + Write-Progress -Id 1 -Activity 'Collecting Activity Explorer data' -PercentComplete $percent ` + -Status ("Window {0}/{1} {2} | {3} / {4} events | {5:0} events/s | remaining {6}" -f $index, ($index + $pending.Count), $label, (Format-DlpNumber $records), (Format-DlpNumber $serviceTotal), $rate, $eta) + + if ($page.LastPage) { break } + if (-not $page.Watermark) { throw 'MissingCookie: LastPage is false but no Watermark was returned.' } + if (-not $cookies.Add($page.Watermark)) { throw 'RepeatedCookie: the paging cookie did not change.' } + $cookie = $page.Watermark + } + $Store.EndInterval($intervalId, $true, $pages, $records, $new, $serviceTotal, $null) + $doneSpan += ($slice.End - $slice.Start) + $stats.SlicesDone++ + if ($serviceTotal) { $stats.ServiceAnnounced += $serviceTotal } + $sliceRate = if ($sliceClock.Elapsed.TotalSeconds -gt 0) { $records / $sliceClock.Elapsed.TotalSeconds } else { 0 } + Write-DlpTableRow -Status Ok -Window $label -Events $records -New $new -Pages $pages -Duration (Format-DlpDuration $sliceClock.Elapsed.TotalSeconds) -Rate ("{0:0} ev/s" -f $sliceRate) + if ($null -ne $serviceTotal -and $serviceTotal -ne $records) { Write-DlpLog 'INFO' "Slice $label : service announced $serviceTotal events, $records received (the announced total is known to be approximate)." } + } catch { + $message = $_.Exception.Message + $Store.EndInterval($intervalId, $false, $pages, $records, $new, $serviceTotal, $message) + Write-DlpLog 'WARN' "Slice $label failed after $pages page(s): $message" + $length = $slice.End - $slice.Start + if ($message -match $permanent) { throw "$label : $message" } + if ($slice.Restart -lt $c.MaxSliceRestarts) { + $stats.Restarts++ + $delay = if ($message -match '(?i)(429|throttl)') { 60 } else { 5 * [Math]::Pow(2, $slice.Restart) } + Write-DlpItem Warn ("{0} interrupted ({1}) - retry {2}/{3} in {4} s" -f $label, (($message -split "`n")[0]), ($slice.Restart + 1), $c.MaxSliceRestarts, $delay) + Start-Sleep -Seconds $delay + [void]$pending.AddFirst([pscustomobject]@{ Start = $slice.Start; End = $slice.End; Restart = $slice.Restart + 1 }) + } elseif ($length -ge 2 * [long]$c.MinimumSliceMinutes * 60000) { + $stats.Subdivisions++ + $middle = $slice.Start + [long][Math]::Floor($length / 2) + Write-DlpItem Warn ("{0} still failing - split into two smaller windows" -f $label) + [void]$pending.AddFirst([pscustomobject]@{ Start = $middle; End = $slice.End; Restart = 0 }) + [void]$pending.AddFirst([pscustomobject]@{ Start = $slice.Start; End = $middle; Restart = 0 }) + } else { throw "$label : $message" } + } + } + $stats.Completed = $true + } catch { + $stats.Error = $_.Exception.Message + } finally { + Write-Progress -Id 1 -Activity 'Collecting Activity Explorer data' -Completed + $stats.Seconds = $clock.Elapsed.TotalSeconds + } + return [pscustomobject]$stats +} + +function Write-DlpCollectionDiagnostics { + param([Parameter(Mandatory)]$Collection, [Parameter(Mandatory)][TimeZoneInfo]$Zone) + if ($Collection.BoundaryEvents) { + Write-DlpItem Info ("Source boundary observations: {0} event(s), {1} newly stored, {2} already in the database (no duplicate inserted)." -f $Collection.BoundaryEvents, $Collection.BoundaryNewEvents, $Collection.BoundaryDuplicates) + Write-DlpItem Info 'Activity Explorer uses second-level request boundaries and an inclusive end. These boundary events were retained; none were discarded. Report date filters still apply.' + } + if ($Collection.UnexpectedOutOfRange) { + Write-DlpItem Warn ("Unexpected timestamps: {0} event(s) outside the expected source boundaries. Events were retained, not discarded; investigate the time difference." -f $Collection.UnexpectedOutOfRange) + foreach ($sample in @($Collection.UnexpectedTimestampSamples | Select-Object -First 3)) { + Write-DlpItem Warn ('Happened {0}; requested [{1}, {2}) ({3}); {4}.' -f + (Format-DlpLocalTime $sample.HappenedMs $Zone 'yyyy-MM-dd HH:mm:ss.fff'), + (Format-DlpLocalTime $sample.StartMs $Zone 'yyyy-MM-dd HH:mm:ss.fff'), + (Format-DlpLocalTime $sample.EndMs $Zone 'yyyy-MM-dd HH:mm:ss.fff'), + $Zone.Id, $(if ($sample.AlreadyPresent) { 'already stored' } else { 'newly stored' })) + } + } +} + +#endregion + +#region 7. Report -------------------------------------------------------------------------------- + +function Move-DlpDirectory { + <# Directory move with retries: antivirus or indexing can briefly lock files that were just written. #> + param([Parameter(Mandatory)][string]$Source, [Parameter(Mandatory)][string]$Destination, [int]$Attempts = 10) + for ($i = 1; $i -le $Attempts; $i++) { + try { [IO.Directory]::Move($Source, $Destination); return } + catch { + $e = $_.Exception; while ($e.InnerException) { $e = $e.InnerException } + $code = $e.HResult -band 0xFFFF + $transient = $e -is [UnauthorizedAccessException] -or $code -in 5, 32, 33 + if (-not $transient -or $i -eq $Attempts) { throw } + Start-Sleep -Milliseconds (250 * $i) + } + } +} + +function New-DlpReport { + <# + .SYNOPSIS + Writes the CSV and HTML files of a period from the database. + .DESCRIPTION + Files are written in a ".pending" directory, which is renamed only when + every file is complete: a folder without ".pending" is always a finished report. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, [Parameter(Mandatory)]$Settings, + [Parameter(Mandatory)]$Period, [Parameter(Mandatory)]$Coverage, + [switch]$CollectionFailed + ) + Assert-DlpPackageIntegrity + $r = $Settings.Report + if (-not [string]::Equals([IO.Path]::GetFullPath($r.TemplatePath), + [IO.Path]::Combine($script:ToolRoot, 'templates\Report.template.html'), [StringComparison]::OrdinalIgnoreCase)) { + throw 'Package integrity: report template must belong to the authenticated package.' + } + $zone = $Settings.Zone + [void][IO.Directory]::CreateDirectory($r.OutputPath) + $name = '{0:yyyy-MM-dd_HHmmss}_{1}' -f (Get-Date), $Period.Range + $final = Join-Path $r.OutputPath $name + for ($n = 2; (Test-Path -LiteralPath $final) -or (Test-Path -LiteralPath "$final.pending"); $n++) { $final = Join-Path $r.OutputPath "${name}_$n" } + $staging = "$final.pending" + $request = [PurviewDlpReport.ReportRequest]::new() + $request.StartMs = $Period.StartMs + $request.EndMs = $Period.EndMs + $request.Zone = $zone + $request.TimeZoneLabel = $r.TimeZone + $request.SplitBy = $r.SplitBy + $request.MaxRowsPerFile = $r.MaxRowsPerFile + $request.IncludeRecipientDetails = [bool]$r.IncludeRecipientDetails + $request.WriteCsv = 'Csv' -in $r.Formats + $request.WriteHtml = 'Html' -in $r.Formats + $request.CsvDelimiter = $r.CsvDelimiter + $request.OutputDirectory = $staging + $request.FilePrefix = $r.FilePrefix + $request.HtmlTemplatePath = $r.TemplatePath + $request.Title = $r.Title + $request.PolicyName = $Settings.Target.PolicyName + $request.RuleName = $Settings.Target.RuleName + $request.ToolVersion = $script:ToolVersion + $request.RangeName = $Period.Range + $request.CoveragePercent = $Coverage.Percent + $request.MissingMs = $Coverage.MissingMs + $request.ProvisionalMs = $Coverage.ProvisionalMs + $request.SettledCoveragePercent = $Coverage.FinalPercent + $request.CollectionSucceeded = -not $CollectionFailed + $request.CompanyName = $r.CompanyName + if ($r.ShowTenantInformation) { + $boundTenant = $Store.GetMetadata('tenant_id') + $request.TenantId = if ($boundTenant) { $boundTenant } else { $Settings.Tenant.TenantId } + $request.TenantOrganization = $Settings.Tenant.Organization + $request.EnvironmentName = $r.EnvironmentName + $request.TenantIdentitySource = if ($boundTenant) { 'Authenticated collection session' } else { 'Configuration only; no authenticated database binding yet' } + $request.LegacyTenantHistoryUnverified = -not $boundTenant -or $Store.GetMetadata('legacy_tenant_history_unverified') -eq 'true' + } + $coverageNotes = [Collections.Generic.List[string]]::new() + if ($Coverage.MissingMs -gt 0) { + $coverageNotes.Add('INCOMPLETE: no completed collection for ' + ((@($Coverage.Gaps) | Select-Object -First 3 | ForEach-Object { Format-DlpRange $_.Start $_.End $zone }) -join ', ') + $(if (@($Coverage.Gaps).Count -gt 3) { ', ...' } else { '' }) + " ($($Coverage.MissingMs) ms missing; the displayed percentage is rounded).") + } + if ($Coverage.ProvisionalMs -gt 0) { + $coverageNotes.Add(('{0}% is settled after the {1}-hour settling window; recent data is provisional.' -f $Coverage.FinalPercent, $Settings.Collection.SettlingHours)) + } + if ($CollectionFailed) { $coverageNotes.Add('INCOMPLETE: the collection or refresh did not finish. Run again to continue.') } + if ($coverageNotes.Count) { $request.CoverageNote = $coverageNotes -join ' ' } + $result = $Store.WriteReport($TargetId, $request) + $metadata = [ordered]@{ + ToolVersion = $script:ToolVersion; Author = 'Nicolas Fabert'; CompanyName = $r.CompanyName + TimeZone = $r.TimeZone + PeriodStartUtc = [DateTimeOffset]::FromUnixTimeMilliseconds($Period.StartMs).ToString('o') + PeriodEndUtc = [DateTimeOffset]::FromUnixTimeMilliseconds($Period.EndMs).ToString('o') + CoverageComplete = $request.CoverageComplete; CoveragePercent = $Coverage.Percent + MissingMs = $Coverage.MissingMs; SettledCoveragePercent = $Coverage.FinalPercent; ProvisionalMs = $Coverage.ProvisionalMs + CollectionSucceeded = $request.CollectionSucceeded + Files = @($result.Files | ForEach-Object { Split-Path $_.Path -Leaf }) + } + if ($r.ShowTenantInformation) { + $metadata.TenantId = $request.TenantId; $metadata.TenantOrganization = $request.TenantOrganization + $metadata.EnvironmentName = $request.EnvironmentName; $metadata.TenantIdentitySource = $request.TenantIdentitySource + $metadata.LegacyTenantHistoryUnverified = $request.LegacyTenantHistoryUnverified + } + $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $staging 'report-metadata.json') -Encoding utf8 + Move-DlpDirectory -Source $staging -Destination $final + foreach ($file in $result.Files) { $file.Path = Join-Path $final (Split-Path $file.Path -Leaf) } + [pscustomobject]@{ Directory = $final; Result = $result } +} + +#endregion + +#region 8. Status and maintenance ------------------------------------------------------------------ + +function Show-DlpStatus { + <# + .SYNOPSIS + Prints the database content and, day by day, what has been collected: + date, messages, a coverage bar and the state (complete, provisional, missing, lost). + #> + [CmdletBinding()] + param([Parameter(Mandatory)]$Store, [Parameter(Mandatory)][long]$TargetId, [Parameter(Mandatory)]$Settings, [int]$Days = 35, + [DateTimeOffset]$Now = [DateTimeOffset]::UtcNow) + $zone = $Settings.Zone; $cfg = $Settings.Collection; $K = $script:C; $dot = [char]0x00B7 + $now = $Now + $nowMs = $now.ToUnixTimeMilliseconds() + $s = $Store.GetStatistics($TargetId) + Write-DlpItem Info ('{0} ({1})' -f $Store.DatabasePath, (Format-DlpBytes $s.FileBytes)) -Icon Database + Write-DlpItem Info ('{0} events {4} {1} messages {4} {2} executions {4} {3} quarantined record(s)' -f (Format-DlpNumber $s.Events), (Format-DlpNumber $s.Messages), (Format-DlpNumber $s.Runs), (Format-DlpNumber $s.Quarantined), $dot) -Icon Chart + if ($null -eq $s.FirstCoveredMs) { Write-DlpItem Warn 'Nothing has been collected yet. Run: .\Invoke-PurviewDlpReport.ps1 -Mode Collect'; return } + Write-DlpItem Info ('History {0} {1} last collection {2}' -f (Format-DlpRange $s.FirstCoveredMs $s.LastCoveredMs $zone), $dot, (Format-DlpLocalTime $s.LastSuccessfulCollectionMs $zone 'yyyy-MM-dd HH:mm:ss')) -Icon Calendar + Write-DlpItem Info ("Read-only snapshot at {0} ({1}). Coverage measures queried time, not collection progress or a percentage of events received." -f (Format-DlpLocalTime $nowMs $zone 'yyyy-MM-dd HH:mm:ss'), $Settings.Report.TimeZone) + Write-DlpItem Info 'Past days use the full local day; today uses midnight to this snapshot. Without another collection, today''s percentage can decrease as time passes.' + Write-Host '' + $today = [TimeZoneInfo]::ConvertTime($now, $zone).DateTime.Date + $first = $today.AddDays( - ($Days - 1)) + $firstCollected = [TimeZoneInfo]::ConvertTime([DateTimeOffset]::FromUnixTimeMilliseconds($s.FirstCoveredMs), $zone).DateTime.Date + if ($firstCollected -gt $first) { $first = $firstCollected } + $Days = [int]($today - $first).TotalDays + 1 + $rows = $Store.GetDailyStatistics($TargetId, $first, $Days, $zone, [long]$cfg.SettlingHours * 3600000) + $sourceStart = $nowMs - [long]$cfg.SourceRetentionDays * 86400000 + $en = [Globalization.CultureInfo]::GetCultureInfo('en-US') + Write-Host (' {0}{1}{2,-15} {3,10} {4,-16} {5}{6}' -f $K.Dim, (' ' + $script:IconPad), 'Day', 'Messages', 'Collected', 'State', $K.Reset) + foreach ($d in $rows) { + if ($d.StartMs -ge $nowMs) { continue } + $length = [Math]::Max(1, [Math]::Min($d.EndMs, $nowMs) - $d.StartMs) + $percent = [int][Math]::Min(100, [Math]::Floor(100.0 * $d.CoveredMs / $length)) + # State, status icon and colour of the day. + if ($d.LocalDate -eq $today) { + $todayCoverage = Get-DlpCoverage -Store $Store -TargetId $TargetId -StartMs $d.StartMs -EndMs $nowMs -SettlingMs ([long]$cfg.SettlingHours * 3600000) + $percent = [int][Math]::Floor(100.0 * ($length - $todayCoverage.MissingMs) / $length) + $state = if ($todayCoverage.MissingMs -eq 0) { + if ($todayCoverage.ProvisionalMs -gt 0) { 'Today covered to snapshot; recent data provisional' } else { 'Today covered to snapshot' } + } else { 'Today partially covered; run -Mode Collect' } + $status = if ($todayCoverage.MissingMs -eq 0) { 'Info' } else { 'Warn' } + } + elseif ($percent -ge 100 -and $d.FinalCoveredMs -ge ($d.EndMs - $d.StartMs)) { $state = 'Complete'; $status = 'Ok' } + elseif ($percent -ge 100) { $state = 'Complete, refreshed at next run'; $status = 'Ok' } + elseif ($d.EndMs -le $sourceStart) { $state = if ($percent -gt 0) { 'Partial, rest no longer collectable' } else { 'Missing, no longer collectable' }; $status = 'Fail' } + elseif ($d.StartMs -lt $sourceStart) { $state = "Partial, earlier hours beyond the $($cfg.SourceRetentionDays)-day retention"; $status = 'Info' } + else { + $left = [Math]::Max(0, [Math]::Floor(($d.StartMs - $sourceStart) / 86400000.0)) + $state = '{0}, collectable {1} more day(s)' -f $(if ($percent -gt 0) { 'Partial' } else { 'Missing' }), $left; $status = 'Warn' + } + $color = @{ Ok = $K.Green; Warn = $K.Yellow; Fail = $K.Red; Info = $K.Cyan }[$status] + # Coverage bar: 12 cells. + $filled = [int][Math]::Round(12 * $percent / 100.0) + $bar = $color + [string]::new([char]0x2588, $filled) + $K.Dim + [string]::new([char]0x2591, 12 - $filled) + $K.Reset + $day = '{0} {1}' -f $d.LocalDate.ToString('ddd', $en), $d.LocalDate.ToString('yyyy-MM-dd') + $messages = if ($d.Messages) { $K.Bold + (Format-DlpNumber $d.Messages).PadLeft(10) + $K.Reset } else { $K.Dim + '0'.PadLeft(10) + $K.Reset } + Write-Host (' {0}{1}{2}{3,-15} {4} {5} {6,4}% {0}{7}{2}' -f $color, (Get-DlpIcon $status), $K.Reset, $day, $messages, $bar, $percent, $state) + Write-DlpLog 'INFO' ("Status {0}: {1} messages, {2} events, {3}% collected, {4}" -f $d.LocalDate.ToString('yyyy-MM-dd'), $d.Messages, $d.Events, $percent, $state) + if ($d.LocalDate -eq $today -and $todayCoverage.MissingMs -gt 0) { + Write-DlpItem Warn ('Not yet covered: {0}. This does not mean stored events were lost.' -f ((@($todayCoverage.Gaps) | Select-Object -First 3 | ForEach-Object { Format-DlpRange $_.Start $_.End $zone }) -join ', ')) + } + } +} +function Invoke-DlpRetention { + <# Deletes database content older than Storage.RetentionDays (0 = keep everything). #> + param([Parameter(Mandatory)]$Store, [Parameter(Mandatory)]$Settings) + if ($Settings.Storage.RetentionDays -le 0) { return $null } + $cutoff = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - [long]$Settings.Storage.RetentionDays * 86400000 + return $Store.PurgeBefore($cutoff) +} + +function Enter-DlpLock { + <# + .SYNOPSIS + Prevents two executions from collecting at the same time (for example the scheduled + task and an administrator). Returns the lock, to be released with Exit-DlpLock. + #> + param([Parameter(Mandatory)][string]$Path, [int]$TimeoutSeconds = 30) + [void][IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ($true) { + try { + $stream = [IO.FileStream]::new($Path, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read) + $stream.SetLength(0) + $bytes = [Text.Encoding]::UTF8.GetBytes(("{0} pid {1} since {2:o}" -f [Environment]::MachineName, $PID, (Get-Date))) + $stream.Write($bytes, 0, $bytes.Length); $stream.Flush() + return $stream + } catch [IO.IOException] { + if ([DateTime]::UtcNow -ge $deadline) { + $owner = try { [IO.File]::ReadAllText($Path) } catch { 'unknown' } + throw "Another execution is already collecting ($owner). Wait for it to finish, or use -NoCollect to build a report from the data already collected." + } + Start-Sleep -Seconds 2 + } + } +} + +function Exit-DlpLock { + param($Lock) + if ($Lock) { $Lock.Dispose() } +} + +#endregion + +# SIG # Begin signature block +# MIIghQYJKoZIhvcNAQcCoIIgdjCCIHICAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCArk4OjRZdwh8SM +# pJ+hUJ5pPIod76/anhS/e1GOJ43kT6CCDW8wggZyMIIEWqADAgECAghkM1HTxzif +# CDANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +# EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8G +# A1UEAwwoU1NMLmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTAe +# Fw0xNjA2MjQyMDQ0MzBaFw0zMTA2MjQyMDQ0MzBaMHgxCzAJBgNVBAYTAlVTMQ4w +# DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjERMA8GA1UECgwIU1NMIENv +# cnAxNDAyBgNVBAMMK1NTTC5jb20gQ29kZSBTaWduaW5nIEludGVybWVkaWF0ZSBD +# QSBSU0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCfgxNzqrDG +# bSHL24t6h3TQcdyOl3Ka5LuINLTdgAPGL0WkdJq/Hg9Q6p5tePOf+lEmqT2d0bKU +# Vz77OYkbkStW72fL5gvjDjmMxjX0jD3dJekBrBdCfVgWQNz51ShEHZVkMGE6ZPKX +# 13NMfXsjAm3zdetVPW+qLcSvvnSsXf5qtvzqXHnpD0OctVIFD+8+sbGP0EmtpuNC +# GVQ/8y8Ooct8/hP5IznaJRy4PgBKOm8yMDdkHseudQfYVdIYyQ6KvKNc8HwKp4WB +# wg6vj5lc02AlvINaaRwlE81y9eucgJvcLGfE3ckJmNVz68Qho+Uyjj4vUpjGYDdk +# jLJvSlRyGMwnh/rNdaJjIUy1PWT9K6abVa8mTGC0uVz+q0O9rdATZlAfC9KJpv/X +# gAbxwxECMzNhF/dWH44vO2jnFfF3VkopngPawismYTJboFblSSmNNqf1x1KiVgMg +# Lzh4gL32Bq5BNMuURb2bx4kYHwu6/6muakCZE93vUN8BuvIE1tAx3zQ4XldbyDge +# VtSsSKbt//m4wTvtwiS+RGCnd83VPZhZtEPqqmB9zcLlL/Hr9dQg1Zc0bl0EawUR +# 0tOSjAknRO1PNTFGfnQZBWLsiePqI3CY5NEv1IoTGEaTZeVYc9NMPSd6Ij/D+KNV +# t/nmh4LsRR7Fbjp8sU65q2j3m2PVkUG8qQIDAQABo4H7MIH4MA8GA1UdEwEB/wQF +# MAMBAf8wHwYDVR0jBBgwFoAU3QQJB6L1en1SUxKSle44gCUNplkwMAYIKwYBBQUH +# AQEEJDAiMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcHMuc3NsLmNvbTARBgNVHSAE +# CjAIMAYGBFUdIAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwOwYDVR0fBDQwMjAwoC6g +# LIYqaHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0G +# A1UdDgQWBBRUwv4QlQCTzWr158DX2bJLuI8M4zAOBgNVHQ8BAf8EBAMCAYYwDQYJ +# KoZIhvcNAQELBQADggIBAPUPJodwr5miyvXWyfCNZj05gtOII9iCv49UhCe204MH +# 154niU2EjlTRIO5gQ9tXQjzHsJX2vszqoz2OTwbGK1mGf+tzG8rlQCbgPW/M9r1x +# xs19DiBAOdYF0q+UCL9/wlG3K7V7gyHwY9rlnOFpLnUdTsthHvWlM98CnRXZ7WmT +# V7pGRS6AvGW+5xI+3kf/kJwQrfZWsqTU+tb8LryXIbN2g9KR+gZQ0bGAKID+260P +# Z+34fdzZcFt6umi1s0pmF4/n8OdX3Wn+vF7h1YyfE7uVmhX7eSuF1W0+Z0duGwdc +# +1RFDxYRLhHDsLy1bhwzV5Qe/kI0Ro4xUE7bM1eV+jjk5hLbq1guRbfZIsr0WkdJ +# LCjoT4xCPGRo6eZDrBmRqccTgl/8cQo3t51Qezxd96JSgjXktefTCm9r/o35pNfV +# HUvnfWII+NnXrJlJ27WEQRQu9i5gl1NLmv7xiHp0up516eDap8nMLDt7TAp4z5T3 +# NmC2gzyKVMtODWgqlBF1JhTqIDfM63kXdlV4cW3iSTgzN9vkbFnHI2LmvM4uVEv9 +# XgMqyN0eS3FE0HU+MWJliymm7STheh2ENH+kF3y0rH0/NVjLw78a3Z9UVm1F5VPz +# iIorMaPKPlDRADTsJwjDZ8Zc6Gi/zy4WZbg8Zv87spWrmo2dzJTw7XhQf+xkR6Od +# MIIG9TCCBN2gAwIBAgIQBof4ljXihJpmCqqt2mhAWzANBgkqhkiG9w0BAQsFADB4 +# MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24x +# ETAPBgNVBAoMCFNTTCBDb3JwMTQwMgYDVQQDDCtTU0wuY29tIENvZGUgU2lnbmlu +# ZyBJbnRlcm1lZGlhdGUgQ0EgUlNBIFIxMB4XDTI2MTAxMzAyNDgwMloXDTI3MTAx +# MzAyNDgwMlowXDELMAkGA1UEBhMCRlIxGzAZBgNVBAcMEkNoYW50ZWxvdXAtRW4t +# QnJpZTEXMBUGA1UECgwOTmljb2xhcyBGYWJlcnQxFzAVBgNVBAMMDk5pY29sYXMg +# RmFiZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsg6CT0mFQ4jB +# V2plIS4uWFSoVqRHd82Y7XYZ4CUTe/7nPBRUlXXdKTZMMHzGgiG8dEXNE8K1Xe90 +# Dgl/FnorzAFlrNWSwf6t27UAUw9xkqtRDVVpVgpixpWkAwjcQeLG+YMqBkGbFi35 +# TGvS23lPFWvLp4DxARrv15MtZ/NjNTCAAGUlMP2hFVBknEmrnYjEGyeJ93sl20RS +# WXQQLNqlenBdkGCrQW4XjHtRjJAEqAC68hN6nWevopSQ8CLUV7A1RFfHFarDrzrL +# zr0/NjIdSyIfM9hShln31NDrktB6Ups2XIyrtPDNB7fjkW4jpLlOqQGfYKsU9ErE +# 4SnFsh0yOOjitegyh1RvZ9wH++3DF4CmJfTli5l/gEIm8xCCryxI+K5EogW3wu1y +# u8NT2hcvHjdh7OpRyPyFAuLZ8MfX7kPAJpnuCeOuktzY/20eVD8bDglj+4zgqzsn +# cR/t6T/7w1/HxpRyrVoKvEyRx+mphYh5jMdEa0BjxOL0vAJpdGZSfXGmPm/yPqRn +# 5STTkMtzkVQXPbcvgKM0luzFcQKPHk3qdHLEXa8gf93ktYDbLE5Wd2UHrXz3SZxN +# URHPdIXCr1qgrqQve7041kMpzrkDI5DnG2lvRG4108gp02bHG0muKyZlDdCOra7h +# Zonu2nO79sFallruzzNi0o994PoWksUCAwEAAaOCAZUwggGRMAwGA1UdEwEB/wQC +# MAAwHwYDVR0jBBgwFoAUVML+EJUAk81q9efA19myS7iPDOMwegYIKwYBBQUHAQEE +# bjBsMEgGCCsGAQUFBzAChjxodHRwOi8vY2VydC5zc2wuY29tL1NTTGNvbS1TdWJD +# QS1Db2RlU2lnbmluZy1SU0EtNDA5Ni1SMS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6 +# Ly9vY3Nwcy5zc2wuY29tMFEGA1UdIARKMEgwCAYGZ4EMAQQBMDwGDCsGAQQBgqkw +# AQMDATAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3JlcG9zaXRv +# cnkwEwYDVR0lBAwwCgYIKwYBBQUHAwMwTQYDVR0fBEYwRDBCoECgPoY8aHR0cDov +# L2NybHMuc3NsLmNvbS9TU0xjb20tU3ViQ0EtQ29kZVNpZ25pbmctUlNBLTQwOTYt +# UjEuY3JsMB0GA1UdDgQWBBRoUx3630l9wA1fUpkKkw2GUIxl/jAOBgNVHQ8BAf8E +# BAMCB4AwDQYJKoZIhvcNAQELBQADggIBAE3oj6cNuwlDToi1BwjgsA5djfRN4DIi +# BYDFZIRYaIRe/rEdnItz3aqQQdFuwXnJA/MQEmi2WAGek8GeZF+R41s8FbcDxBUE +# BoSt98TuBE+0xgxQWDiPhhXaX1Aw9V0GF4ZnlfCSw1eu2KiASaKn7Bo0PLbEAXff +# vftRXof/kyqtw8iID6I4Rhrf5DI3WVZQodakUUmWnbnziWvEZOWbNOfzwP+7BwEt +# 7jQANfbmEfb4p75rfy5dQVwniTDSvdo6xkrbe1Cr93GS7cJta18IG5CoIVUmdK+s +# W38B1sLuy8Mi0MstMXV3004yaRuzLdNaAM0f9WAFerxemiQC0kdmA44/lWV3lVkM +# WJSwkHBkkSg8Eh0hxcGzTH2xmtDhLJHNNLRwkI9u2Ur+HbLCt50oBgSV8BcCs/1i +# qi884iC/yuX+HIv9YgTUNZ8c4lLaq4VuliJpXLu2buHIsTkO0v0+5oCM91zjTVZm +# ULeH38N0Vr83psiJVuBSxQyt5nRVrpYz7FACq5gtXELkyViq/rGOlJKaRrgQ6WWq +# eoIXqKhVFAdP/jAEm2M8ezoVYsUgPyKY2OcuecV9coulAcxUIUydx6qYiOfsGj5u +# Dy7l4aYjvB771wkUMzUQwxzcDYXJtMMTaWRe6n9EemR67jc73u5CJyFg3PfA5WR1 +# wZislcdCZuS6MYISbDCCEmgCAQEwgYwweDELMAkGA1UEBhMCVVMxDjAMBgNVBAgM +# BVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDE0MDIG +# A1UEAwwrU1NMLmNvbSBDb2RlIFNpZ25pbmcgSW50ZXJtZWRpYXRlIENBIFJTQSBS +# MQIQBof4ljXihJpmCqqt2mhAWzANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3 +# AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisG +# AQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCWg0fYeWij +# bpCpWfGlmvWMT/2Hu1iwWcjUFGyc/LAaujANBgkqhkiG9w0BAQEFAASCAgAD0nHd +# 2oMVVubxdIZ4v02ClMW83v5RRxt+3xpoLSpiOp52LqM7C9l9+fU68NQsvKAq2sP9 +# zElIiY2keXB79zHXS+mwElq4cPSOxf/cOSXyxLTaprZgeO4ZzAug5XlK6B4haJ1I +# ro7Lz/qOkUaxiTdobx7Ax+QBHBoTnumvM6hpcnbLFjOxh6GapshA/PqmzHktiAdu +# FQZmly1F5B2Rp1mihiYAlkbGpnCYYU5hkYjNekUcxR6ua0yfBEMVnvcdJQDnALyX +# hwpWqZOs8Lp84u7X3t2q5ssp3gZ3xj5GKKX16RdLRRU4SFlU0n59kUdc9oWSmcUx +# 3hetUbq80A8WZrEr6BClpbEJ3RbE9WEh9rG1XnH9KirOagizfc4zOqHLwyB+JpVy +# hr+1j3aUX9ilSmcJRyVv5JYscGfVeejNVMCc9v2NUNJ69PLhuNIn9mRVcThJL2Ni +# eEUdNPVw3O4LQ5I5FD2pKQ2f9q/nM8kJKGq/DoWNik+Q+FsTyAhLN33lWX/VCCF2 +# bPRb9/yUR+kAvX+O5eL6uJGdckzR8SH/80qJR+2nSQsK8mNJbr4VkIqOavNPH2Dh +# +AtGUF5vZK1qAz5f2oCR7mx/G2snoSw29P7/3dSsAuqKCpBWofvr1p5Uh/LPg/V2 +# Go2vKV0f1N45A7BFw/HQvgNlLdmu3DccQuAOAqGCDykwgg8lBgorBgEEAYI3AwMB +# MYIPFTCCDxEGCSqGSIb3DQEHAqCCDwIwgg7+AgEDMQ0wCwYJYIZIAWUDBAIBMIGJ +# BgsqhkiG9w0BCRABBKB6BHgwdgIBAQYMKwYBBAGCqTABAwYBMDEwDQYJYIZIAWUD +# BAIBBQAEIGcZ3XuZxcYXBy7unwvQ78j2gIHW/yunXORrW/7DsZlXAggZTVxi6CVD +# whgPMjAyNjEwMTMxMTQyNTJaMAMCAQECEEtdHfsDOiBRDvmOhGHhT6mgggwAMIIE +# /DCCAuSgAwIBAgIQH2sWYtIuG2xd8cDBoGAOODANBgkqhkiG9w0BAQsFADBzMQsw +# CQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24xETAP +# BgNVBAoMCFNTTCBDb3JwMS8wLQYDVQQDDCZTU0wuY29tIFRpbWVzdGFtcGluZyBJ +# c3N1aW5nIFJTQSBDQSBSMTAeFw0yNTAyMTgxNjMyMDJaFw0zNDExMTIxODUwMDVa +# MG4xCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3Rv +# bjERMA8GA1UECgwIU1NMIENvcnAxKjAoBgNVBAMMIVNTTC5jb20gVGltZXN0YW1w +# aW5nIFVuaXQgMjAyNSBFMTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBv7UVHH +# b+ZVluxPXlfE3M6tg0Xnq8dic+O3vPOCRpalUM1vO9A+GRzSVVjyygHhYrBw62XL +# Fh1kv7e+yRd/aTajggFaMIIBVjAfBgNVHSMEGDAWgBQMnRAljpqnG5mHQ88IfuG9 +# gZD0zzBRBggrBgEFBQcBAQRFMEMwQQYIKwYBBQUHMAKGNWh0dHA6Ly9jZXJ0LnNz +# bC5jb20vU1NMLmNvbS10aW1lU3RhbXBpbmctSS1SU0EtUjEuY2VyMFEGA1UdIARK +# MEgwPAYMKwYBBAGCqTABAwYBMCwwKgYIKwYBBQUHAgEWHmh0dHBzOi8vd3d3LnNz +# bC5jb20vcmVwb3NpdG9yeTAIBgZngQwBBAIwFgYDVR0lAQH/BAwwCgYIKwYBBQUH +# AwgwRgYDVR0fBD8wPTA7oDmgN4Y1aHR0cDovL2NybHMuc3NsLmNvbS9TU0wuY29t +# LXRpbWVTdGFtcGluZy1JLVJTQS1SMS5jcmwwHQYDVR0OBBYEFM582cAEgMUkEGoJ +# 6hyrJT0R/ajSMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAgHN1 +# JIW9ZlNip07FfY9r1LJwLKTl1h/y9r1BjDgHMEz7U7J7mAy32JxA1KLyjCCJqNku +# RfhDHGFEfZtjTOru7AQMDSDiUDvCCc3jaz5py9jGvwIe5npkLbXqXhA+SqCzG719 +# USigwhHkYr7v8NiXoaY0Xu03p0BiZZreZlpngmGB9+86N94FqIFx2I5Z5iuu7d4j +# 7aAOZBNxfU9jFDIIGDtWfWeCpF0Q8rLE/3DHDlzJKs5Il3i/VoOl7rTU938oSrxM +# www6GDBlKX8rGHxvkjgIb9MXkVglJSDHsNAZDeCS/inaxKHA+ChuB8K4OxIxa6k1 +# e+eHBypakAY3wuN7w7PlPmhN6k5IdqZn3HZHs2VjoR841Z9wmVPFdGDdkBZ7XOmb +# 5OZUaKufsvtER0VbY2DkzaqAaItt1kc+I+FUz7PiVz2PUzpegkftRRxvrPyIL08b +# lJTnbMQ0XrqDN0rEfuTLv4XhhnKyJaXhxCUdjO/cRumMzZ35QTUMItBWy1xGp0iy +# oFVnAya2pPsVMuAI+sy1zxlOS9l5iSxvKJ8gpnpOgjqYTa7u1eyZo+4JlcGoiiR1 +# 7LthMTrF1q62tO5xOpI8txUZ6gtKGtkJV6wdj+vZLcwKZ2M93GqVpOo6UBlGjx1s +# KsVZ7AIUzJZli4NJ0M2KYcrfNnlebkYKpDnSqWwwggb8MIIE5KADAgECAhBtUhhw +# h+gjTYVgANCAj5NWMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMQ4wDAYD +# VQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBv +# cmF0aW9uMTEwLwYDVQQDDChTU0wuY29tIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRo +# b3JpdHkgUlNBMB4XDTE5MTExMzE4NTAwNVoXDTM0MTExMjE4NTAwNVowczELMAkG +# A1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYD +# VQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNvbSBUaW1lc3RhbXBpbmcgSXNz +# dWluZyBSU0EgQ0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCu +# URAT0vk8IKAghd7JUBxkyeH9xek0/wp/MUjoclrFXqhh/fGH91Fc+7fm0MHCE7A+ +# wmOiqBj9ODrJAYGq3rm33jCnHSsCBNWAQYyoauLq8IjqsS1JlXL29qDNMMdwZ8UN +# zQS7vWZMDJ40JSGNphMGTIA2qn2bohGtgRc4p1395ESypUOaGvJ3t0FNL3BuKmb6 +# YctMcQUF2sqooMzd89h0E6ujdvBDo6ZwNnWoxj7YmfWjSXg33A5GuY9ym4QZM5OE +# Vgo8ebz/B+gyhyCLNNhh4Mb/4xvCTCMVmNYrBviGgdPZYrym8Zb84TQCmSuX0JlL +# La6WK1aO6qlwISbb9bVGh866ekKblC/XRP20gAu1CjvcYciUgNTrGFg8f8AJgQPO +# Cc1/CCdaJSYwhJpSdheKOnQgESgNmYZPhFOC6IKaMAUXk5U1tjTcFCgFvvArXtK4 +# azAWUOO1Y3fdldIBL6LjkzLUCYJNkFXqhsBVcPMuB0nUDWvLJfPimstjJ8lF4S6E +# CxWnlWi7OElVwTnt1GtRqeY9ydvvGLntU+FecK7DbqHDUd366UreMkSBtzevAc9a +# qoZPnjVMjvFqV1pYOjzmTiVHZtAc80bAfFe5LLfJzPI6DntNyqobpwTevQpHqPDN +# 9qqNO83r3kaw8A9j+HZiSw2AX5cGdQP0kG0vhzfgBwIDAQABo4IBgTCCAX0wEgYD +# VR0TAQH/BAgwBgEB/wIBADAfBgNVHSMEGDAWgBTdBAkHovV6fVJTEpKV7jiAJQ2m +# WTCBgwYIKwYBBQUHAQEEdzB1MFEGCCsGAQUFBzAChkVodHRwOi8vd3d3LnNzbC5j +# b20vcmVwb3NpdG9yeS9TU0xjb21Sb290Q2VydGlmaWNhdGlvbkF1dGhvcml0eVJT +# QS5jcnQwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3Nwcy5zc2wuY29tMD8GA1UdIAQ4 +# MDYwNAYEVR0gADAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3Jl +# cG9zaXRvcnkwEwYDVR0lBAwwCgYIKwYBBQUHAwgwOwYDVR0fBDQwMjAwoC6gLIYq +# aHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0GA1Ud +# DgQWBBQMnRAljpqnG5mHQ88IfuG9gZD0zzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +# hvcNAQELBQADggIBAJIZdQ2mWkLPGQfZ8vyU+sCb8BXpRJZaL3Ez3VDlE3uZk3cP +# xPtybVfLuqaci0W6SB22JTMttCiQMnIVOsXWnIuAbD/aFTcUkTLBI3xys+wEajzX +# aXJYWACDS47BRjDtYlDW14gLJxf8W6DQoH3jHDGGy8kGJFOlDKG7/YrK7UGfHtBA +# EDVe6lyZ+FtCsrk7dD/IiL/+Q3Q6SFASJLQ2XI89ihFugdYL77CiDNXrI2MFspQG +# swXEAGpHuaQDTHUp/LdR3TyrIsLlnzoLskUGswF/KF8+kpWUiKJNC4rPWtNrxlbX +# YRGgdEdx8SMjUTDClldcrknlFxbqHsVmr9xkT2QtFmG+dEq1v5fsIK0vHaHrWjMM +# maJ9i+4qGJSD0stYfQ6v0PddT7EpGxGd867Ada6FZyHwbuQSadMb0K0P0OC2r7rw +# qBUe0BaMqTa6LWzWItgBjGcObXeMxmbQqlEz2YtAcErkZvh0WABDDE4U8GyV/32F +# daAvJgTfe9MiL2nSBioYe/g5mHUSWAay/Ip1RQmQCvmF9sNfqlhJwkjy/1U1ibUk +# TIUBX3HgymyQvqQTZLLys6pL2tCdWcjI9YuLw30rgZm8+K387L7ycUvqrmQ3ZJlu +# jHl3r1hgV76s3WwMPgKk1bAEFMj+rRXimSC+Ev30hXZdqyMdl/il5Ksd0vhGMYIC +# WDCCAlQCAQEwgYcwczELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD +# VQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNv +# bSBUaW1lc3RhbXBpbmcgSXNzdWluZyBSU0EgQ0EgUjECEB9rFmLSLhtsXfHAwaBg +# DjgwCwYJYIZIAWUDBAIBoIIBYTAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQw +# HAYJKoZIhvcNAQkFMQ8XDTI2MTAxMzExNDI1MlowKAYJKoZIhvcNAQk0MRswGTAL +# BglghkgBZQMEAgGhCgYIKoZIzj0EAwIwLwYJKoZIhvcNAQkEMSIEIERqWnt760BL +# 8U+vc1SAmsdP54qo3TlTT54ch89JHhCYMIHJBgsqhkiG9w0BCRACLzGBuTCBtjCB +# szCBsAQgVCr5oWqNci5mEUl4iumUwYqaruWmXLNEolSa+Wx5x4swgYswd6R1MHMx +# CzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjER +# MA8GA1UECgwIU1NMIENvcnAxLzAtBgNVBAMMJlNTTC5jb20gVGltZXN0YW1waW5n +# IElzc3VpbmcgUlNBIENBIFIxAhAfaxZi0i4bbF3xwMGgYA44MAoGCCqGSM49BAMC +# BEcwRQIgQKbuVMTmc4UQZA/jr7A9S/dZKiGl1oAjaqT9ISh767MCIQDPQ6SjazRA +# 4B11Wm8YKaVx8EHko2CxlwsgJNudYJXQrQ== +# SIG # End signature block diff --git a/package/README.md b/package/README.md index 14f4e77..5985326 100644 --- a/package/README.md +++ b/package/README.md @@ -11,6 +11,18 @@ This folder contains everything needed to run the tool: `Invoke-PurviewDlpReport > Get-ChildItem . -Recurse -File | Unblock-File > ``` +## Signed package integrity (2.1.2) + +The five signed PowerShell files (launcher, module, module metadata, verifier and `PackageIntegrity.psd1`) require valid Nicolas Fabert signatures and RFC3161 timestamps. Expected signer: `0F28E442889F17B5F4D251E8F96E7CC6E2C9C6D6`. The verifier authenticates sixteen stable raw SHA256 entries, including the C# source, original vendor DLLs, template, README and both HTML guides. Missing or changed files, unexpected stable code and protected reparse points fail before code loading; there is no unsigned fallback. + +Edit only `config\PurviewDlpReport.config.psd1`. `data`, `logs`, `reports` and old `bin` output are not trusted code. Repository-only `LICENSE`, the two Markdown guides and the ten named `docs\images\*.png` sources are an explicit, unhashed documentation boundary; they are not loaded at runtime or copied by the packager. Unknown documentation names and executable files are not exempt. + +Review the publisher and trusted chain before unblocking downloads. Follow your organization's execution policy; do not disable it or integrity verification to resolve an error. Chain/revocation checks can need access to issuer services; an unavailable or untrusted chain is a blocking failure, not a reason to import arbitrary trust roots. See the HTML developer guide for source edits and certificate rotation. + +Verified C# compiles in memory once per process. Startup recompilation and warm checks have overhead; unsigned engine caches are never loaded. PowerShell 7.4+ is required (business tests used 7.6.6); Windows PowerShell 5.1 is for signature inspection only. Offline and limited functional testing are not broad performance or production validation. + +Coverage measures queried time. Check `report-metadata.json` and the exit code: a rounded 100% or a folder without `.pending` does not prove complete coverage. Tenant/environment display is disabled by default; company branding is independent of the fixed author. + ## Requirements - PowerShell 7.4 or later; 7.6 for ExchangeOnlineManagement 3.10 or later. @@ -37,7 +49,7 @@ notepad .\config\PurviewDlpReport.config.psd1 # TenantId, PolicyId, RuleId, | `config\` | Configuration file to fill in. | | `docs\` | User and developer guides, Markdown and self-contained HTML. | | `lib\` | Bundled SQLite libraries. | -| `src\` | C# engine source, compiled on first use. | +| `src\` | Authenticated C# engine source, compiled in memory once per process. | | `templates\` | HTML report template. | | `Invoke-PurviewDlpReport.ps1` | Entry script. | | `PurviewDlpReport.psd1` | Module manifest. | diff --git a/package/Test-PackageIntegrity.ps1 b/package/Test-PackageIntegrity.ps1 new file mode 100644 index 0000000..edc76b3 --- /dev/null +++ b/package/Test-PackageIntegrity.ps1 @@ -0,0 +1,297 @@ +#Requires -Version 7.4 +[CmdletBinding()] +param([Parameter(Mandatory)][string]$Root) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$signer = '0F28E442889F17B5F4D251E8F96E7CC6E2C9C6D6' +$rootPath = [IO.Path]::GetFullPath($Root).TrimEnd('\') +$required = @( + 'Invoke-PurviewDlpReport.ps1' + 'PurviewDlpReport.psm1' + 'PurviewDlpReport.psd1' + 'Test-PackageIntegrity.ps1' + 'src\PurviewDlpReport.Engine.cs' + 'lib\sqlite\Microsoft.Data.Sqlite.dll' + 'lib\sqlite\SQLitePCLRaw.core.dll' + 'lib\sqlite\SQLitePCLRaw.provider.e_sqlite3.dll' + 'lib\sqlite\SQLitePCLRaw.batteries_v2.dll' + 'lib\sqlite\runtimes\win-x64\e_sqlite3.dll' + 'lib\sqlite\runtimes\win-arm64\e_sqlite3.dll' + 'templates\Report.template.html' + 'docs\PurviewDlpReport-Guide.html' + 'docs\PurviewDlpReport-UserGuide.html' + 'README.md' + 'THIRD-PARTY-NOTICES.md' +) +# Repository-only documents are not loaded by the engine or copied into the distribution. +# These exact names (not an extension or directory wildcard) are the only unhashed source extras. +$sourceDocuments = @( + 'LICENSE' + 'docs\PurviewDlpReport-Guide.md' + 'docs\PurviewDlpReport-UserGuide.md' + 'docs\images\console-report.png' + 'docs\images\console-status.png' + 'docs\images\readme-banner-dark.png' + 'docs\images\readme-banner-light.png' + 'docs\images\readme-how-it-works-dark.png' + 'docs\images\readme-how-it-works-light.png' + 'docs\images\readme-principles-dark.png' + 'docs\images\readme-principles-light.png' + 'docs\images\report-details.png' + 'docs\images\report-overview.png' +) +function Assert-PlainPath([string]$Path, [bool]$Directory = $false) { + $item = Microsoft.PowerShell.Management\Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint -or $item.PSIsContainer -ne $Directory) { + throw "Package integrity: unsafe path or unexpected type: $Path" + } + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Path)) + while ($parent) { + $ancestor = Microsoft.PowerShell.Management\Get-Item -LiteralPath $parent -Force -ErrorAction Stop + if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Package integrity: reparse-point ancestor: $parent" + } + $parent = [IO.Path]::GetDirectoryName($parent) + } +} +function Assert-OwnerSignature([string]$Path) { + $signature = Microsoft.PowerShell.Security\Get-AuthenticodeSignature -LiteralPath $Path -ErrorAction Stop + if ([string]$signature.Status -cne 'Valid' -or $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -cne $signer -or $null -eq $signature.TimeStamperCertificate) { + throw "Package integrity: expected trusted, timestamped owner signature: $Path" + } +} +Assert-PlainPath $rootPath $true +if (-not [string]::Equals([IO.Path]::GetFullPath($PSCommandPath), + [IO.Path]::Combine($rootPath, 'Test-PackageIntegrity.ps1'), [StringComparison]::OrdinalIgnoreCase)) { + throw 'Package integrity: verifier must belong to this package root.' +} +Assert-PlainPath $PSCommandPath +Assert-OwnerSignature $PSCommandPath +$manifestPath = [IO.Path]::Combine($rootPath, 'PackageIntegrity.psd1') +Assert-PlainPath $manifestPath +Assert-OwnerSignature $manifestPath +# Authenticate the raw PSD1 before parsing its restricted data language. +$manifest = Microsoft.PowerShell.Utility\Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction Stop +if ($manifest.Count -ne 4 -or -not $manifest.ContainsKey('SchemaVersion') -or + -not $manifest.ContainsKey('Algorithm') -or -not $manifest.ContainsKey('SignerThumbprint') -or + -not $manifest.ContainsKey('Files') -or $manifest.SchemaVersion -isnot [int] -or + $manifest.SchemaVersion -ne 1 -or $manifest.Algorithm -cne 'SHA256' -or + $manifest.SignerThumbprint -cne $signer -or $manifest.Files -isnot [array] -or + $manifest.Files.Count -ne $required.Count) { + throw 'Package integrity: unsupported or incomplete manifest schema.' +} +$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($entry in $manifest.Files) { + if ($entry -isnot [hashtable] -or $entry.Count -ne 2 -or + -not $entry.ContainsKey('Path') -or -not $entry.ContainsKey('Sha256') -or + $entry.Path -isnot [string] -or $entry.Sha256 -isnot [string] -or + $entry.Path -cnotin $required -or $entry.Sha256 -cnotmatch '^[0-9A-F]{64}$' -or + -not $seen.Add($entry.Path)) { + throw 'Package integrity: invalid, duplicate or unapproved inventory path/hash.' + } + $path = [IO.Path]::Combine($rootPath, $entry.Path) + Assert-PlainPath $path + $hash = (Microsoft.PowerShell.Utility\Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash + if ($hash -cne $entry.Sha256) { throw "Package integrity: SHA256 mismatch: $($entry.Path)" } + if ($entry.Path -cmatch '\.ps(m1|d1|1)$') { Assert-OwnerSignature $path } +} +foreach ($path in $required) { + if (-not $seen.Contains($path)) { throw "Package integrity: missing inventory path: $path" } +} +$queue = [Collections.Generic.Queue[string]]::new() +$queue.Enqueue($rootPath) +while ($queue.Count) { + $directory = $queue.Dequeue() + foreach ($item in Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) { + $relative = [IO.Path]::GetRelativePath($rootPath, $item.FullName) + if ($directory -ceq $rootPath -and $item.PSIsContainer -and $item.Name -cin @('bin', 'data', 'logs', 'reports')) { + continue + } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Package integrity: protected reparse point: $relative" + } + if ($item.PSIsContainer) { $queue.Enqueue($item.FullName); continue } + if ($relative -ceq 'PackageIntegrity.psd1' -or $relative -ceq 'config\PurviewDlpReport.config.psd1') { continue } + if ($relative -cin $sourceDocuments) { continue } + if ($relative -cnotin $required) { throw "Package integrity: unexpected stable file: $relative" } + } +} + +# SIG # Begin signature block +# MIIghgYJKoZIhvcNAQcCoIIgdzCCIHMCAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAk4hRAgfmiQolR +# 0l3aqWF6d9dxcyu0vU1a+g3qzhdWvaCCDW8wggZyMIIEWqADAgECAghkM1HTxzif +# CDANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +# EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8G +# A1UEAwwoU1NMLmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTAe +# Fw0xNjA2MjQyMDQ0MzBaFw0zMTA2MjQyMDQ0MzBaMHgxCzAJBgNVBAYTAlVTMQ4w +# DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjERMA8GA1UECgwIU1NMIENv +# cnAxNDAyBgNVBAMMK1NTTC5jb20gQ29kZSBTaWduaW5nIEludGVybWVkaWF0ZSBD +# QSBSU0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCfgxNzqrDG +# bSHL24t6h3TQcdyOl3Ka5LuINLTdgAPGL0WkdJq/Hg9Q6p5tePOf+lEmqT2d0bKU +# Vz77OYkbkStW72fL5gvjDjmMxjX0jD3dJekBrBdCfVgWQNz51ShEHZVkMGE6ZPKX +# 13NMfXsjAm3zdetVPW+qLcSvvnSsXf5qtvzqXHnpD0OctVIFD+8+sbGP0EmtpuNC +# GVQ/8y8Ooct8/hP5IznaJRy4PgBKOm8yMDdkHseudQfYVdIYyQ6KvKNc8HwKp4WB +# wg6vj5lc02AlvINaaRwlE81y9eucgJvcLGfE3ckJmNVz68Qho+Uyjj4vUpjGYDdk +# jLJvSlRyGMwnh/rNdaJjIUy1PWT9K6abVa8mTGC0uVz+q0O9rdATZlAfC9KJpv/X +# gAbxwxECMzNhF/dWH44vO2jnFfF3VkopngPawismYTJboFblSSmNNqf1x1KiVgMg +# Lzh4gL32Bq5BNMuURb2bx4kYHwu6/6muakCZE93vUN8BuvIE1tAx3zQ4XldbyDge +# VtSsSKbt//m4wTvtwiS+RGCnd83VPZhZtEPqqmB9zcLlL/Hr9dQg1Zc0bl0EawUR +# 0tOSjAknRO1PNTFGfnQZBWLsiePqI3CY5NEv1IoTGEaTZeVYc9NMPSd6Ij/D+KNV +# t/nmh4LsRR7Fbjp8sU65q2j3m2PVkUG8qQIDAQABo4H7MIH4MA8GA1UdEwEB/wQF +# MAMBAf8wHwYDVR0jBBgwFoAU3QQJB6L1en1SUxKSle44gCUNplkwMAYIKwYBBQUH +# AQEEJDAiMCAGCCsGAQUFBzABhhRodHRwOi8vb2NzcHMuc3NsLmNvbTARBgNVHSAE +# CjAIMAYGBFUdIAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwOwYDVR0fBDQwMjAwoC6g +# LIYqaHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0G +# A1UdDgQWBBRUwv4QlQCTzWr158DX2bJLuI8M4zAOBgNVHQ8BAf8EBAMCAYYwDQYJ +# KoZIhvcNAQELBQADggIBAPUPJodwr5miyvXWyfCNZj05gtOII9iCv49UhCe204MH +# 154niU2EjlTRIO5gQ9tXQjzHsJX2vszqoz2OTwbGK1mGf+tzG8rlQCbgPW/M9r1x +# xs19DiBAOdYF0q+UCL9/wlG3K7V7gyHwY9rlnOFpLnUdTsthHvWlM98CnRXZ7WmT +# V7pGRS6AvGW+5xI+3kf/kJwQrfZWsqTU+tb8LryXIbN2g9KR+gZQ0bGAKID+260P +# Z+34fdzZcFt6umi1s0pmF4/n8OdX3Wn+vF7h1YyfE7uVmhX7eSuF1W0+Z0duGwdc +# +1RFDxYRLhHDsLy1bhwzV5Qe/kI0Ro4xUE7bM1eV+jjk5hLbq1guRbfZIsr0WkdJ +# LCjoT4xCPGRo6eZDrBmRqccTgl/8cQo3t51Qezxd96JSgjXktefTCm9r/o35pNfV +# HUvnfWII+NnXrJlJ27WEQRQu9i5gl1NLmv7xiHp0up516eDap8nMLDt7TAp4z5T3 +# NmC2gzyKVMtODWgqlBF1JhTqIDfM63kXdlV4cW3iSTgzN9vkbFnHI2LmvM4uVEv9 +# XgMqyN0eS3FE0HU+MWJliymm7STheh2ENH+kF3y0rH0/NVjLw78a3Z9UVm1F5VPz +# iIorMaPKPlDRADTsJwjDZ8Zc6Gi/zy4WZbg8Zv87spWrmo2dzJTw7XhQf+xkR6Od +# MIIG9TCCBN2gAwIBAgIQBof4ljXihJpmCqqt2mhAWzANBgkqhkiG9w0BAQsFADB4 +# MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24x +# ETAPBgNVBAoMCFNTTCBDb3JwMTQwMgYDVQQDDCtTU0wuY29tIENvZGUgU2lnbmlu +# ZyBJbnRlcm1lZGlhdGUgQ0EgUlNBIFIxMB4XDTI2MTAxMzAyNDgwMloXDTI3MTAx +# MzAyNDgwMlowXDELMAkGA1UEBhMCRlIxGzAZBgNVBAcMEkNoYW50ZWxvdXAtRW4t +# QnJpZTEXMBUGA1UECgwOTmljb2xhcyBGYWJlcnQxFzAVBgNVBAMMDk5pY29sYXMg +# RmFiZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsg6CT0mFQ4jB +# V2plIS4uWFSoVqRHd82Y7XYZ4CUTe/7nPBRUlXXdKTZMMHzGgiG8dEXNE8K1Xe90 +# Dgl/FnorzAFlrNWSwf6t27UAUw9xkqtRDVVpVgpixpWkAwjcQeLG+YMqBkGbFi35 +# TGvS23lPFWvLp4DxARrv15MtZ/NjNTCAAGUlMP2hFVBknEmrnYjEGyeJ93sl20RS +# WXQQLNqlenBdkGCrQW4XjHtRjJAEqAC68hN6nWevopSQ8CLUV7A1RFfHFarDrzrL +# zr0/NjIdSyIfM9hShln31NDrktB6Ups2XIyrtPDNB7fjkW4jpLlOqQGfYKsU9ErE +# 4SnFsh0yOOjitegyh1RvZ9wH++3DF4CmJfTli5l/gEIm8xCCryxI+K5EogW3wu1y +# u8NT2hcvHjdh7OpRyPyFAuLZ8MfX7kPAJpnuCeOuktzY/20eVD8bDglj+4zgqzsn +# cR/t6T/7w1/HxpRyrVoKvEyRx+mphYh5jMdEa0BjxOL0vAJpdGZSfXGmPm/yPqRn +# 5STTkMtzkVQXPbcvgKM0luzFcQKPHk3qdHLEXa8gf93ktYDbLE5Wd2UHrXz3SZxN +# URHPdIXCr1qgrqQve7041kMpzrkDI5DnG2lvRG4108gp02bHG0muKyZlDdCOra7h +# Zonu2nO79sFallruzzNi0o994PoWksUCAwEAAaOCAZUwggGRMAwGA1UdEwEB/wQC +# MAAwHwYDVR0jBBgwFoAUVML+EJUAk81q9efA19myS7iPDOMwegYIKwYBBQUHAQEE +# bjBsMEgGCCsGAQUFBzAChjxodHRwOi8vY2VydC5zc2wuY29tL1NTTGNvbS1TdWJD +# QS1Db2RlU2lnbmluZy1SU0EtNDA5Ni1SMS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6 +# Ly9vY3Nwcy5zc2wuY29tMFEGA1UdIARKMEgwCAYGZ4EMAQQBMDwGDCsGAQQBgqkw +# AQMDATAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3JlcG9zaXRv +# cnkwEwYDVR0lBAwwCgYIKwYBBQUHAwMwTQYDVR0fBEYwRDBCoECgPoY8aHR0cDov +# L2NybHMuc3NsLmNvbS9TU0xjb20tU3ViQ0EtQ29kZVNpZ25pbmctUlNBLTQwOTYt +# UjEuY3JsMB0GA1UdDgQWBBRoUx3630l9wA1fUpkKkw2GUIxl/jAOBgNVHQ8BAf8E +# BAMCB4AwDQYJKoZIhvcNAQELBQADggIBAE3oj6cNuwlDToi1BwjgsA5djfRN4DIi +# BYDFZIRYaIRe/rEdnItz3aqQQdFuwXnJA/MQEmi2WAGek8GeZF+R41s8FbcDxBUE +# BoSt98TuBE+0xgxQWDiPhhXaX1Aw9V0GF4ZnlfCSw1eu2KiASaKn7Bo0PLbEAXff +# vftRXof/kyqtw8iID6I4Rhrf5DI3WVZQodakUUmWnbnziWvEZOWbNOfzwP+7BwEt +# 7jQANfbmEfb4p75rfy5dQVwniTDSvdo6xkrbe1Cr93GS7cJta18IG5CoIVUmdK+s +# W38B1sLuy8Mi0MstMXV3004yaRuzLdNaAM0f9WAFerxemiQC0kdmA44/lWV3lVkM +# WJSwkHBkkSg8Eh0hxcGzTH2xmtDhLJHNNLRwkI9u2Ur+HbLCt50oBgSV8BcCs/1i +# qi884iC/yuX+HIv9YgTUNZ8c4lLaq4VuliJpXLu2buHIsTkO0v0+5oCM91zjTVZm +# ULeH38N0Vr83psiJVuBSxQyt5nRVrpYz7FACq5gtXELkyViq/rGOlJKaRrgQ6WWq +# eoIXqKhVFAdP/jAEm2M8ezoVYsUgPyKY2OcuecV9coulAcxUIUydx6qYiOfsGj5u +# Dy7l4aYjvB771wkUMzUQwxzcDYXJtMMTaWRe6n9EemR67jc73u5CJyFg3PfA5WR1 +# wZislcdCZuS6MYISbTCCEmkCAQEwgYwweDELMAkGA1UEBhMCVVMxDjAMBgNVBAgM +# BVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDE0MDIG +# A1UEAwwrU1NMLmNvbSBDb2RlIFNpZ25pbmcgSW50ZXJtZWRpYXRlIENBIFJTQSBS +# MQIQBof4ljXihJpmCqqt2mhAWzANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3 +# AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisG +# AQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCAwcQqaRlCX +# 0ZzcjDFPCqi2DK5eaDNAv8s+URurldoCOzANBgkqhkiG9w0BAQEFAASCAgCi8oQK +# PZNkk6lpzFmIBp4ahb/JrMyyipwk3d6i3xdQgnC7fTqtj9xofCNPdcXg2LJ3j/ig +# HU1r4E87OhpzQ4Iip05m47slmsBvl1DPtJwK9WHZi/9Zv1f/OiZ6p/B5fqq/y4T4 +# 2RvQ5kgo3aji5ThzbomaSuRZZvOPEInDe/CLKqI+aeIs+YwDCRvxmPidRPnQl5Il +# CSddsJw0AEjzisyURELQmhxDbNql0i0enBbzXLGoDSf4M+lJ0l0PntFH9FEaQ8gX +# tOU67iHOBLvFsCdMRw47i+XsmMSmI+1ZFb0kbEEuFnXr1eDWZjv44A2uC/+BHEVr +# fL4pzOX24Uw5byjJ1uxURR9qWhDqcuCWtqrnLZfT56Nr7Drg0SU7flgC7ZuspxSi +# Z52JkGF+EmbzMG/oL568AEwVbwrfilkYhBsi/T+ithP8zugQcKS749L00Rdcl5+G +# uaGBlmg3tMk5Ra7cXsYUUcWNn0ZnfONPTwzQxg5GxqyR91V1WbECHiAKJ7/CVAzW +# fqjRuZpBJZqvp9qzikFn81V5q4eFTnDLYorfsD0p5qRFgE2ld7feSx0gCGI9EXlG +# T/aQqNHMvHCt928CQDjLWvS5WzNv7GCB6LCYhRCnkqxz36Ei/2o+ctKDRzEnH/TV +# bcx6UgaBZ1827mipRWzpoYqF2arAwUKOqgcWMaGCDyowgg8mBgorBgEEAYI3AwMB +# MYIPFjCCDxIGCSqGSIb3DQEHAqCCDwMwgg7/AgEDMQ0wCwYJYIZIAWUDBAIBMIGJ +# BgsqhkiG9w0BCRABBKB6BHgwdgIBAQYMKwYBBAGCqTABAwYBMDEwDQYJYIZIAWUD +# BAIBBQAEIHhjXj+AgKbuFZ/HBQb7OOWnA89MRI9vCXp3/yS0vgIQAgg0L34SVfwI +# nhgPMjAyNjEwMTMxMTQyNTlaMAMCAQECECkFSJNXnbL+L2kuzBloPjOgggwAMIIE +# /DCCAuSgAwIBAgIQH2sWYtIuG2xd8cDBoGAOODANBgkqhkiG9w0BAQsFADBzMQsw +# CQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0b24xETAP +# BgNVBAoMCFNTTCBDb3JwMS8wLQYDVQQDDCZTU0wuY29tIFRpbWVzdGFtcGluZyBJ +# c3N1aW5nIFJTQSBDQSBSMTAeFw0yNTAyMTgxNjMyMDJaFw0zNDExMTIxODUwMDVa +# MG4xCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3Rv +# bjERMA8GA1UECgwIU1NMIENvcnAxKjAoBgNVBAMMIVNTTC5jb20gVGltZXN0YW1w +# aW5nIFVuaXQgMjAyNSBFMTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBv7UVHH +# b+ZVluxPXlfE3M6tg0Xnq8dic+O3vPOCRpalUM1vO9A+GRzSVVjyygHhYrBw62XL +# Fh1kv7e+yRd/aTajggFaMIIBVjAfBgNVHSMEGDAWgBQMnRAljpqnG5mHQ88IfuG9 +# gZD0zzBRBggrBgEFBQcBAQRFMEMwQQYIKwYBBQUHMAKGNWh0dHA6Ly9jZXJ0LnNz +# bC5jb20vU1NMLmNvbS10aW1lU3RhbXBpbmctSS1SU0EtUjEuY2VyMFEGA1UdIARK +# MEgwPAYMKwYBBAGCqTABAwYBMCwwKgYIKwYBBQUHAgEWHmh0dHBzOi8vd3d3LnNz +# bC5jb20vcmVwb3NpdG9yeTAIBgZngQwBBAIwFgYDVR0lAQH/BAwwCgYIKwYBBQUH +# AwgwRgYDVR0fBD8wPTA7oDmgN4Y1aHR0cDovL2NybHMuc3NsLmNvbS9TU0wuY29t +# LXRpbWVTdGFtcGluZy1JLVJTQS1SMS5jcmwwHQYDVR0OBBYEFM582cAEgMUkEGoJ +# 6hyrJT0R/ajSMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsFAAOCAgEAgHN1 +# JIW9ZlNip07FfY9r1LJwLKTl1h/y9r1BjDgHMEz7U7J7mAy32JxA1KLyjCCJqNku +# RfhDHGFEfZtjTOru7AQMDSDiUDvCCc3jaz5py9jGvwIe5npkLbXqXhA+SqCzG719 +# USigwhHkYr7v8NiXoaY0Xu03p0BiZZreZlpngmGB9+86N94FqIFx2I5Z5iuu7d4j +# 7aAOZBNxfU9jFDIIGDtWfWeCpF0Q8rLE/3DHDlzJKs5Il3i/VoOl7rTU938oSrxM +# www6GDBlKX8rGHxvkjgIb9MXkVglJSDHsNAZDeCS/inaxKHA+ChuB8K4OxIxa6k1 +# e+eHBypakAY3wuN7w7PlPmhN6k5IdqZn3HZHs2VjoR841Z9wmVPFdGDdkBZ7XOmb +# 5OZUaKufsvtER0VbY2DkzaqAaItt1kc+I+FUz7PiVz2PUzpegkftRRxvrPyIL08b +# lJTnbMQ0XrqDN0rEfuTLv4XhhnKyJaXhxCUdjO/cRumMzZ35QTUMItBWy1xGp0iy +# oFVnAya2pPsVMuAI+sy1zxlOS9l5iSxvKJ8gpnpOgjqYTa7u1eyZo+4JlcGoiiR1 +# 7LthMTrF1q62tO5xOpI8txUZ6gtKGtkJV6wdj+vZLcwKZ2M93GqVpOo6UBlGjx1s +# KsVZ7AIUzJZli4NJ0M2KYcrfNnlebkYKpDnSqWwwggb8MIIE5KADAgECAhBtUhhw +# h+gjTYVgANCAj5NWMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMQ4wDAYD +# VQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBv +# cmF0aW9uMTEwLwYDVQQDDChTU0wuY29tIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRo +# b3JpdHkgUlNBMB4XDTE5MTExMzE4NTAwNVoXDTM0MTExMjE4NTAwNVowczELMAkG +# A1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMREwDwYD +# VQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNvbSBUaW1lc3RhbXBpbmcgSXNz +# dWluZyBSU0EgQ0EgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCu +# URAT0vk8IKAghd7JUBxkyeH9xek0/wp/MUjoclrFXqhh/fGH91Fc+7fm0MHCE7A+ +# wmOiqBj9ODrJAYGq3rm33jCnHSsCBNWAQYyoauLq8IjqsS1JlXL29qDNMMdwZ8UN +# zQS7vWZMDJ40JSGNphMGTIA2qn2bohGtgRc4p1395ESypUOaGvJ3t0FNL3BuKmb6 +# YctMcQUF2sqooMzd89h0E6ujdvBDo6ZwNnWoxj7YmfWjSXg33A5GuY9ym4QZM5OE +# Vgo8ebz/B+gyhyCLNNhh4Mb/4xvCTCMVmNYrBviGgdPZYrym8Zb84TQCmSuX0JlL +# La6WK1aO6qlwISbb9bVGh866ekKblC/XRP20gAu1CjvcYciUgNTrGFg8f8AJgQPO +# Cc1/CCdaJSYwhJpSdheKOnQgESgNmYZPhFOC6IKaMAUXk5U1tjTcFCgFvvArXtK4 +# azAWUOO1Y3fdldIBL6LjkzLUCYJNkFXqhsBVcPMuB0nUDWvLJfPimstjJ8lF4S6E +# CxWnlWi7OElVwTnt1GtRqeY9ydvvGLntU+FecK7DbqHDUd366UreMkSBtzevAc9a +# qoZPnjVMjvFqV1pYOjzmTiVHZtAc80bAfFe5LLfJzPI6DntNyqobpwTevQpHqPDN +# 9qqNO83r3kaw8A9j+HZiSw2AX5cGdQP0kG0vhzfgBwIDAQABo4IBgTCCAX0wEgYD +# VR0TAQH/BAgwBgEB/wIBADAfBgNVHSMEGDAWgBTdBAkHovV6fVJTEpKV7jiAJQ2m +# WTCBgwYIKwYBBQUHAQEEdzB1MFEGCCsGAQUFBzAChkVodHRwOi8vd3d3LnNzbC5j +# b20vcmVwb3NpdG9yeS9TU0xjb21Sb290Q2VydGlmaWNhdGlvbkF1dGhvcml0eVJT +# QS5jcnQwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3Nwcy5zc2wuY29tMD8GA1UdIAQ4 +# MDYwNAYEVR0gADAsMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5zc2wuY29tL3Jl +# cG9zaXRvcnkwEwYDVR0lBAwwCgYIKwYBBQUHAwgwOwYDVR0fBDQwMjAwoC6gLIYq +# aHR0cDovL2NybHMuc3NsLmNvbS9zc2wuY29tLXJzYS1Sb290Q0EuY3JsMB0GA1Ud +# DgQWBBQMnRAljpqnG5mHQ88IfuG9gZD0zzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +# hvcNAQELBQADggIBAJIZdQ2mWkLPGQfZ8vyU+sCb8BXpRJZaL3Ez3VDlE3uZk3cP +# xPtybVfLuqaci0W6SB22JTMttCiQMnIVOsXWnIuAbD/aFTcUkTLBI3xys+wEajzX +# aXJYWACDS47BRjDtYlDW14gLJxf8W6DQoH3jHDGGy8kGJFOlDKG7/YrK7UGfHtBA +# EDVe6lyZ+FtCsrk7dD/IiL/+Q3Q6SFASJLQ2XI89ihFugdYL77CiDNXrI2MFspQG +# swXEAGpHuaQDTHUp/LdR3TyrIsLlnzoLskUGswF/KF8+kpWUiKJNC4rPWtNrxlbX +# YRGgdEdx8SMjUTDClldcrknlFxbqHsVmr9xkT2QtFmG+dEq1v5fsIK0vHaHrWjMM +# maJ9i+4qGJSD0stYfQ6v0PddT7EpGxGd867Ada6FZyHwbuQSadMb0K0P0OC2r7rw +# qBUe0BaMqTa6LWzWItgBjGcObXeMxmbQqlEz2YtAcErkZvh0WABDDE4U8GyV/32F +# daAvJgTfe9MiL2nSBioYe/g5mHUSWAay/Ip1RQmQCvmF9sNfqlhJwkjy/1U1ibUk +# TIUBX3HgymyQvqQTZLLys6pL2tCdWcjI9YuLw30rgZm8+K387L7ycUvqrmQ3ZJlu +# jHl3r1hgV76s3WwMPgKk1bAEFMj+rRXimSC+Ev30hXZdqyMdl/il5Ksd0vhGMYIC +# WTCCAlUCAQEwgYcwczELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD +# VQQHDAdIb3VzdG9uMREwDwYDVQQKDAhTU0wgQ29ycDEvMC0GA1UEAwwmU1NMLmNv +# bSBUaW1lc3RhbXBpbmcgSXNzdWluZyBSU0EgQ0EgUjECEB9rFmLSLhtsXfHAwaBg +# DjgwCwYJYIZIAWUDBAIBoIIBYTAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQw +# HAYJKoZIhvcNAQkFMQ8XDTI2MTAxMzExNDI1OVowKAYJKoZIhvcNAQk0MRswGTAL +# BglghkgBZQMEAgGhCgYIKoZIzj0EAwIwLwYJKoZIhvcNAQkEMSIEICXVQgdWdu+t +# q+dJLSlDeMxilqsuLitRKOmFekK/aurGMIHJBgsqhkiG9w0BCRACLzGBuTCBtjCB +# szCBsAQgVCr5oWqNci5mEUl4iumUwYqaruWmXLNEolSa+Wx5x4swgYswd6R1MHMx +# CzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjER +# MA8GA1UECgwIU1NMIENvcnAxLzAtBgNVBAMMJlNTTC5jb20gVGltZXN0YW1waW5n +# IElzc3VpbmcgUlNBIENBIFIxAhAfaxZi0i4bbF3xwMGgYA44MAoGCCqGSM49BAMC +# BEgwRgIhAI98pqsBUif9vcNh7DKp8WbCdGAeyikYOiPnlD0q5nFnAiEA5cjZ3f3m +# rAPbtE3RqOqjMysKyEEMoWDrWmzYQk6UNc0= +# SIG # End signature block diff --git a/package/config/PurviewDlpReport.config.psd1 b/package/config/PurviewDlpReport.config.psd1 index 8aaa64c..19496d0 100644 --- a/package/config/PurviewDlpReport.config.psd1 +++ b/package/config/PurviewDlpReport.config.psd1 @@ -2,7 +2,7 @@ # Purview DLP Report - configuration file # -------------------------------------------------------------------------- # Author : Nicolas Fabert -# Version : 2.1.1 +# Version : 2.1.2 # # This file is read by Invoke-PurviewDlpReport.ps1. It is a PowerShell data # file: text between quotes, $true / $false, numbers, and @( ) for lists. @@ -99,6 +99,10 @@ MaxRowsPerFile = 500000 # no split below this number of messages; maximum 1,048,575 (Excel limit) CsvDelimiter = ';' # ';' opens directly in Excel with French regional settings Title = 'DLP messages with more than 25 recipients' + CompanyName = '' # optional company name in HTML and report-metadata.json + ShowTenantInformation = $false # opt-in only: tenant and environment in HTML and metadata (not CSV) + EnvironmentName = '' # optional administrator-defined environment label + # author is fixed to Nicolas Fabert, not configurable } # --------------------------------------------------------------------- diff --git a/package/docs/PurviewDlpReport-Guide.html b/package/docs/PurviewDlpReport-Guide.html index 594871d..4820b78 100644 --- a/package/docs/PurviewDlpReport-Guide.html +++ b/package/docs/PurviewDlpReport-Guide.html @@ -155,9 +155,9 @@ main { padding: 0; max-width: none; } .section { break-inside: avoid-page; box-shadow: none; } .part { break-before: page; } } -