Skip to content

Instantly share code, notes, and snippets.

@PeterGabaldon
Created September 2, 2026 08:29
Show Gist options
  • Select an option

  • Save PeterGabaldon/b8eb845669869b6dca0936a55f280dbb to your computer and use it in GitHub Desktop.

Select an option

Save PeterGabaldon/b8eb845669869b6dca0936a55f280dbb to your computer and use it in GitHub Desktop.
Cloudflare DMARC Reports Exporter
[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