Created
September 2, 2026 08:29
-
-
Save PeterGabaldon/b8eb845669869b6dca0936a55f280dbb to your computer and use it in GitHub Desktop.
Cloudflare DMARC Reports Exporter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [ValidatePattern('^[0-9a-fA-F]{32}$')] | |
| [string]$ZoneTag, | |
| [Parameter(Mandatory)] | |
| [Security.SecureString]$XAtok, | |
| [Parameter(Mandatory)] | |
| [Security.SecureString]$CookieHeader, | |
| [Parameter()] | |
| [ValidatePattern('^\d{4}-\d{2}-\d{2}$')] | |
| [string]$StartDate = (Get-Date).ToUniversalTime().Date.AddDays(-30).ToString('yyyy-MM-dd'), | |
| [Parameter()] | |
| [ValidatePattern('^\d{4}-\d{2}-\d{2}$')] | |
| [string]$EndDate = (Get-Date).ToUniversalTime().Date.ToString('yyyy-MM-dd'), | |
| [Parameter()] | |
| [string]$OutputDirectory = (Join-Path (Get-Location) 'cloudflare-dmarc-export'), | |
| [Parameter()] | |
| [ValidateRange(1, 10000)] | |
| [int]$Limit = 10000, | |
| [Parameter()] | |
| [string]$Endpoint = 'https://dash.cloudflare.com/api/v4/graphql', | |
| [Parameter()] | |
| [string]$UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36' | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| if ($PSVersionTable.PSVersion.Major -lt 5) { | |
| throw 'PowerShell 5.1 or newer is required.' | |
| } | |
| [Net.ServicePointManager]::SecurityProtocol = | |
| [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 | |
| function ConvertFrom-SecureValue { | |
| param([Parameter(Mandatory)][Security.SecureString]$Value) | |
| return ([Net.NetworkCredential]::new('', $Value)).Password | |
| } | |
| function ConvertTo-UtcDay { | |
| param( | |
| [Parameter(Mandatory)][string]$Value, | |
| [Parameter(Mandatory)][string]$ParameterName | |
| ) | |
| try { | |
| return [DateTime]::ParseExact( | |
| $Value, | |
| 'yyyy-MM-dd', | |
| [Globalization.CultureInfo]::InvariantCulture, | |
| [Globalization.DateTimeStyles]::AssumeUniversal | |
| ).Date | |
| } | |
| catch { | |
| throw "$ParameterName must be a valid date in yyyy-MM-dd format." | |
| } | |
| } | |
| $startUtc = ConvertTo-UtcDay -Value $StartDate -ParameterName 'StartDate' | |
| $endUtc = ConvertTo-UtcDay -Value $EndDate -ParameterName 'EndDate' | |
| if ($startUtc -gt $endUtc) { | |
| throw 'StartDate cannot be later than EndDate.' | |
| } | |
| $dayCount = [int](($endUtc - $startUtc).TotalDays + 1) | |
| $outputPath = [IO.Path]::GetFullPath($OutputDirectory) | |
| $rawPath = Join-Path $outputPath 'raw' | |
| [void](New-Item -ItemType Directory -Path $rawPath -Force) | |
| $utf8NoBom = [Text.UTF8Encoding]::new($false) | |
| function Write-JsonFile { | |
| param( | |
| [Parameter(Mandatory)][string]$Path, | |
| [Parameter(Mandatory)]$Value | |
| ) | |
| $json = $Value | ConvertTo-Json -Depth 50 | |
| [IO.File]::WriteAllText($Path, $json, $utf8NoBom) | |
| } | |
| $xAtokPlain = ConvertFrom-SecureValue -Value $XAtok | |
| $cookiePlain = ConvertFrom-SecureValue -Value $CookieHeader | |
| $headers = @{ | |
| Accept = '*/*' | |
| 'Accept-Language' = 'en-US,en;q=0.9' | |
| 'Cache-Control' = 'no-cache' | |
| Cookie = $cookiePlain | |
| Origin = 'https://dash.cloudflare.com' | |
| Pragma = 'no-cache' | |
| Referer = 'https://dash.cloudflare.com/' | |
| 'Sec-Fetch-Dest' = 'empty' | |
| 'Sec-Fetch-Mode' = 'cors' | |
| 'Sec-Fetch-Site' = 'same-origin' | |
| 'x-atok' = $xAtokPlain | |
| 'x-cross-site-security' = 'dash' | |
| } | |
| function Invoke-CloudflareGraphQL { | |
| param( | |
| [Parameter(Mandatory)][string]$Query, | |
| [Parameter(Mandatory)][hashtable]$Variables, | |
| [Parameter(Mandatory)][string]$Context | |
| ) | |
| $requestBody = [ordered]@{ | |
| query = $Query | |
| variables = $Variables | |
| } | ConvertTo-Json -Depth 20 -Compress | |
| $response = $null | |
| for ($attempt = 1; $attempt -le 4; $attempt++) { | |
| try { | |
| $response = Invoke-RestMethod ` | |
| -Uri $Endpoint ` | |
| -Method POST ` | |
| -Headers $headers ` | |
| -ContentType 'application/json' ` | |
| -UserAgent $UserAgent ` | |
| -Body $requestBody ` | |
| -TimeoutSec 120 | |
| break | |
| } | |
| catch { | |
| $statusCode = 0 | |
| if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { | |
| try { | |
| $statusCode = [int]$_.Exception.Response.StatusCode | |
| } | |
| catch { | |
| $statusCode = 0 | |
| } | |
| } | |
| if ($statusCode -in 401, 403) { | |
| throw "$Context failed with HTTP $statusCode. Capture a fresh x-atok and Cookie header from the same Cloudflare dashboard session, and keep the User-Agent identical to the browser session." | |
| } | |
| $retryable = ($statusCode -eq 0 -or $statusCode -eq 429 -or $statusCode -ge 500) | |
| if (-not $retryable -or $attempt -eq 4) { | |
| throw "$Context failed with HTTP $statusCode. $($_.Exception.Message)" | |
| } | |
| $delaySeconds = [Math]::Pow(2, $attempt) | |
| Write-Warning "$Context failed with HTTP $statusCode; retrying in $delaySeconds seconds." | |
| Start-Sleep -Seconds $delaySeconds | |
| } | |
| } | |
| if ($null -eq $response) { | |
| throw "$Context returned no response." | |
| } | |
| if ($response.errors) { | |
| $errorText = $response.errors | ConvertTo-Json -Depth 20 -Compress | |
| throw "$Context returned GraphQL errors: $errorText" | |
| } | |
| return $response | |
| } | |
| $servicesQuery = @' | |
| { | |
| viewer { | |
| zones(filter: {zoneTag: $zoneTag}) { | |
| dmarcReportsSourcesAdaptiveGroups(limit: __LIMIT__, filter: $filter, orderBy: [sum_totalMatchingMessages_DESC]) { | |
| dimensions { | |
| sourceOrgName | |
| sourceOrgSlug | |
| datetimeDay | |
| } | |
| sum { | |
| totalMatchingMessages | |
| dmarc | |
| dkimPass | |
| spfPass | |
| } | |
| uniq { | |
| ipCount | |
| } | |
| __typename | |
| } | |
| __typename | |
| } | |
| __typename | |
| } | |
| } | |
| '@ | |
| $servicesQuery = $servicesQuery.Replace('__LIMIT__', [string]$Limit) | |
| $detailsQuery = @' | |
| { | |
| viewer { | |
| zones(filter: {zoneTag: $zoneTag}) { | |
| dmarcReportsSourcesAdaptiveGroups(limit: __LIMIT__, filter: $filter, orderBy: [sum_totalMatchingMessages_DESC]) { | |
| dimensions { | |
| datetimeDay | |
| domain | |
| orgName | |
| sourceOrgName | |
| sourceOrgSlug | |
| sourceLocationName | |
| sourceIP | |
| envelopeTo | |
| envelopeFrom | |
| evaluatedReason | |
| dkim | |
| spf | |
| } | |
| sum { | |
| totalMatchingMessages | |
| dmarc | |
| dkimPass | |
| spfPass | |
| } | |
| uniq { | |
| ipCount | |
| } | |
| __typename | |
| } | |
| __typename | |
| } | |
| __typename | |
| } | |
| } | |
| '@ | |
| $detailsQuery = $detailsQuery.Replace('__LIMIT__', [string]$Limit) | |
| $startFilter = $startUtc.ToString('yyyy-MM-ddT00:00:00.000Z') | |
| $endFilter = $endUtc.ToString('yyyy-MM-ddT00:00:00.000Z') | |
| $warnings = [Collections.Generic.List[string]]::new() | |
| $possibleTruncation = $false | |
| if ($dayCount -gt 31) { | |
| $warnings.Add("The requested range is $dayCount inclusive days. Cloudflare DMARC Management normally exposes approximately 30 days; older dates may return no data.") | |
| } | |
| Write-Host "Downloading sending-service summary for $StartDate through $EndDate..." | |
| $servicesVariables = @{ | |
| zoneTag = $ZoneTag | |
| filter = @{ | |
| datetimeDay_geq = $startFilter | |
| datetimeDay_leq = $endFilter | |
| sourceOrgSlug_neq = '' | |
| } | |
| } | |
| $servicesResponse = Invoke-CloudflareGraphQL ` | |
| -Query $servicesQuery ` | |
| -Variables $servicesVariables ` | |
| -Context 'Sending-service summary' | |
| $servicesRawFile = Join-Path $rawPath 'services-summary.json' | |
| Write-JsonFile -Path $servicesRawFile -Value $servicesResponse | |
| $servicesZone = @($servicesResponse.data.viewer.zones)[0] | |
| if ($null -eq $servicesZone) { | |
| throw 'The service-summary response did not contain the requested zone. Verify ZoneTag and dashboard access.' | |
| } | |
| $serviceDailyRows = @( | |
| $servicesZone.dmarcReportsSourcesAdaptiveGroups | | |
| Where-Object { $null -ne $_ } | |
| ) | |
| if ($serviceDailyRows.Count -ge $Limit) { | |
| $possibleTruncation = $true | |
| $warnings.Add("The service-summary query returned the configured limit of $Limit rows and may be truncated.") | |
| } | |
| $serviceMap = [ordered]@{} | |
| foreach ($row in $serviceDailyRows) { | |
| $slug = [string]$row.dimensions.sourceOrgSlug | |
| if ([string]::IsNullOrWhiteSpace($slug)) { | |
| $slug = '<unidentified>' | |
| } | |
| if (-not $serviceMap.Contains($slug)) { | |
| $serviceMap[$slug] = [ordered]@{ | |
| sourceOrgSlug = $slug | |
| sourceOrgName = [string]$row.dimensions.sourceOrgName | |
| totalMatchingMessages = [Int64]0 | |
| dmarcPass = [Int64]0 | |
| dkimPass = [Int64]0 | |
| spfPass = [Int64]0 | |
| daysPresent = [int]0 | |
| } | |
| } | |
| $entry = $serviceMap[$slug] | |
| if ([string]::IsNullOrWhiteSpace([string]$entry.sourceOrgName) -and | |
| -not [string]::IsNullOrWhiteSpace([string]$row.dimensions.sourceOrgName)) { | |
| $entry.sourceOrgName = [string]$row.dimensions.sourceOrgName | |
| } | |
| if ($null -ne $row.sum.totalMatchingMessages) { | |
| $entry.totalMatchingMessages += [Int64]$row.sum.totalMatchingMessages | |
| } | |
| if ($null -ne $row.sum.dmarc) { | |
| $entry.dmarcPass += [Int64]$row.sum.dmarc | |
| } | |
| if ($null -ne $row.sum.dkimPass) { | |
| $entry.dkimPass += [Int64]$row.sum.dkimPass | |
| } | |
| if ($null -ne $row.sum.spfPass) { | |
| $entry.spfPass += [Int64]$row.sum.spfPass | |
| } | |
| $entry.daysPresent++ | |
| } | |
| $uniqueServices = @( | |
| $serviceMap.Values | | |
| ForEach-Object { [pscustomobject]$_ } | | |
| Sort-Object totalMatchingMessages -Descending | |
| ) | |
| $detailRows = [Collections.Generic.List[object]]::new() | |
| $rawDetailFiles = [Collections.Generic.List[string]]::new() | |
| $currentDay = $startUtc | |
| while ($currentDay -le $endUtc) { | |
| $dayText = $currentDay.ToString('yyyy-MM-dd') | |
| $dayFilter = $currentDay.ToString('yyyy-MM-ddT00:00:00.000Z') | |
| Write-Host "Downloading detailed DMARC groups for $dayText..." | |
| $detailsVariables = @{ | |
| zoneTag = $ZoneTag | |
| filter = @{ | |
| datetimeDay_geq = $dayFilter | |
| datetimeDay_leq = $dayFilter | |
| } | |
| } | |
| $detailsResponse = Invoke-CloudflareGraphQL ` | |
| -Query $detailsQuery ` | |
| -Variables $detailsVariables ` | |
| -Context "DMARC details for $dayText" | |
| $rawDetailFile = Join-Path $rawPath "details-$dayText.json" | |
| Write-JsonFile -Path $rawDetailFile -Value $detailsResponse | |
| $rawDetailFiles.Add($rawDetailFile) | |
| $detailsZone = @($detailsResponse.data.viewer.zones)[0] | |
| if ($null -eq $detailsZone) { | |
| throw "The details response for $dayText did not contain the requested zone." | |
| } | |
| $dayRows = @( | |
| $detailsZone.dmarcReportsSourcesAdaptiveGroups | | |
| Where-Object { $null -ne $_ } | |
| ) | |
| foreach ($row in $dayRows) { | |
| $detailRows.Add($row) | |
| } | |
| if ($dayRows.Count -ge $Limit) { | |
| $possibleTruncation = $true | |
| $warnings.Add("The detailed query for $dayText returned the configured limit of $Limit rows and may be truncated.") | |
| } | |
| $currentDay = $currentDay.AddDays(1) | |
| } | |
| $combined = [ordered]@{ | |
| schemaVersion = 1 | |
| generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') | |
| source = [ordered]@{ | |
| endpoint = $Endpoint | |
| dataset = 'dmarcReportsSourcesAdaptiveGroups' | |
| zoneTag = $ZoneTag | |
| } | |
| requestedRange = [ordered]@{ | |
| startDate = $StartDate | |
| endDate = $EndDate | |
| inclusiveDays = $dayCount | |
| } | |
| completeness = [ordered]@{ | |
| possibleTruncation = $possibleTruncation | |
| rowLimitPerQuery = $Limit | |
| warnings = @($warnings) | |
| } | |
| counts = [ordered]@{ | |
| uniqueIdentifiedServices = $uniqueServices.Count | |
| serviceDailyRows = $serviceDailyRows.Count | |
| detailedRows = $detailRows.Count | |
| detailedRequests = $dayCount | |
| } | |
| identifiedServices = $uniqueServices | |
| serviceDailySummary = $serviceDailyRows | |
| sourceDetailsDaily = $detailRows | |
| } | |
| $combinedFile = Join-Path $outputPath 'cloudflare-dmarc-export.json' | |
| Write-JsonFile -Path $combinedFile -Value $combined | |
| $fileManifest = @( | |
| Get-ChildItem -Path $outputPath -Filter '*.json' -File -Recurse | | |
| Sort-Object FullName | | |
| ForEach-Object { | |
| [pscustomobject]@{ | |
| path = $_.FullName.Substring($outputPath.Length).TrimStart('\', '/') | |
| sizeBytes = $_.Length | |
| sha256 = (Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() | |
| } | |
| } | |
| ) | |
| $manifestFile = Join-Path $outputPath 'manifest.json' | |
| Write-JsonFile -Path $manifestFile -Value ([ordered]@{ | |
| generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') | |
| files = $fileManifest | |
| }) | |
| $combinedHash = (Get-FileHash -Path $combinedFile -Algorithm SHA256).Hash.ToLowerInvariant() | |
| Write-Host '' | |
| Write-Host 'Export complete.' | |
| Write-Host "Identified services: $($uniqueServices.Count)" | |
| Write-Host "Daily service rows: $($serviceDailyRows.Count)" | |
| Write-Host "Detailed rows: $($detailRows.Count)" | |
| Write-Host "Combined JSON: $combinedFile" | |
| Write-Host "Combined JSON SHA-256: $combinedHash" | |
| if ($warnings.Count -gt 0) { | |
| Write-Warning ($warnings -join ' ') | |
| } | |
| if ($possibleTruncation) { | |
| Write-Warning 'At least one query reached the row limit. The output is marked as possibly truncated; do not treat it as complete until that date is split further.' | |
| } | |
| # Remove plaintext references from the script scope as soon as requests finish. | |
| $headers.Cookie = $null | |
| $headers.'x-atok' = $null | |
| $cookiePlain = $null | |
| $xAtokPlain = $null |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment