dmarc-report 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dmarc_report/__init__.py +1 -0
- dmarc_report/dmarc_parser.py +565 -0
- dmarc_report/report.py +34 -0
- dmarc_report-0.1.0.dist-info/METADATA +48 -0
- dmarc_report-0.1.0.dist-info/RECORD +8 -0
- dmarc_report-0.1.0.dist-info/WHEEL +4 -0
- dmarc_report-0.1.0.dist-info/entry_points.txt +2 -0
- dmarc_report-0.1.0.dist-info/licenses/LICENSE +21 -0
dmarc_report/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DMARC report parser and display tool."""
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Parse DMARC XML reports and display the results using Rich tables and panels."""
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import zipfile
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar, Optional
|
|
10
|
+
from xml.etree.ElementTree import Element
|
|
11
|
+
|
|
12
|
+
from defusedxml import ElementTree
|
|
13
|
+
from rich import box
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PolicyType(Enum):
|
|
20
|
+
"""Policy types for DMARC."""
|
|
21
|
+
|
|
22
|
+
NONE = "none"
|
|
23
|
+
QUARANTINE = "quarantine"
|
|
24
|
+
REJECT = "reject"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AlignmentMode(Enum):
|
|
28
|
+
"""Alignment modes for DKIM and SPF."""
|
|
29
|
+
|
|
30
|
+
RELAXED = "r"
|
|
31
|
+
STRICT = "s"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthResultType(Enum):
|
|
35
|
+
"""Authentication result types for DKIM and SPF.
|
|
36
|
+
|
|
37
|
+
The AuthResultType enum corresponds to the <result> element in the <dkim> and <spf> elements of the DMARC XML
|
|
38
|
+
report.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
NONE = "none"
|
|
42
|
+
PASS = "pass" # noqa: S105
|
|
43
|
+
FAIL = "fail"
|
|
44
|
+
POLICY = "policy"
|
|
45
|
+
NEUTRAL = "neutral"
|
|
46
|
+
TEMPERROR = "temperror"
|
|
47
|
+
PERMERROR = "permerror"
|
|
48
|
+
SOFTFAIL = "softfail" # SPF only
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class DateRange:
|
|
53
|
+
"""Date range object containing begin and end timestamps.
|
|
54
|
+
|
|
55
|
+
The DateRange object corresponds to the <date_range> element in the DMARC XML report.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
begin: int
|
|
59
|
+
end: int
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
"""Return formatted date range string."""
|
|
63
|
+
return f"{self.format_timestamp(self.begin)} to {self.format_timestamp(self.end)}"
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def format_timestamp(timestamp: int) -> str:
|
|
67
|
+
"""Convert UTC Unix timestamp to formatted UTC date string."""
|
|
68
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") # noqa: UP017
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ReportMetadata:
|
|
73
|
+
"""Report metadata object containing report recipient details.
|
|
74
|
+
|
|
75
|
+
The ReportMetadata object corresponds to the <report_metadata> element in the DMARC XML report.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
org_name: str
|
|
79
|
+
email: str
|
|
80
|
+
report_id: str
|
|
81
|
+
date_range: DateRange
|
|
82
|
+
extra_contact_info: Optional[str] = None
|
|
83
|
+
errors: Optional[list[str]] = None # Not yet implemented
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class PolicyPublished:
|
|
88
|
+
"""Policy published object containing DMARC policy details.
|
|
89
|
+
|
|
90
|
+
The PolicyPublished object corresponds to the <policy_published> element in the DMARC XML report.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
domain: str
|
|
94
|
+
p: PolicyType
|
|
95
|
+
sp: PolicyType
|
|
96
|
+
pct: int # 0-100
|
|
97
|
+
adkim: AlignmentMode
|
|
98
|
+
aspf: AlignmentMode
|
|
99
|
+
fo: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
"""Convert string values to enums if needed and validate percentage."""
|
|
103
|
+
if isinstance(self.p, str):
|
|
104
|
+
self.p = PolicyType(self.p)
|
|
105
|
+
if isinstance(self.sp, str):
|
|
106
|
+
self.sp = PolicyType(self.sp)
|
|
107
|
+
if isinstance(self.adkim, str):
|
|
108
|
+
self.adkim = AlignmentMode(self.adkim)
|
|
109
|
+
if isinstance(self.aspf, str):
|
|
110
|
+
self.aspf = AlignmentMode(self.aspf)
|
|
111
|
+
|
|
112
|
+
# Validate percentage
|
|
113
|
+
if not 0 <= self.pct <= 100: # noqa: PLR2004
|
|
114
|
+
msg = f"Percentage must be between 0 and 100, got {self.pct}"
|
|
115
|
+
raise ValueError(msg)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class PolicyEvaluated:
|
|
120
|
+
"""Policy evaluated object containing disposition and authentication results.
|
|
121
|
+
|
|
122
|
+
The PolicyEvaluated object corresponds to the <policy_evaluated> element in the <record> element of DMARC XML
|
|
123
|
+
report.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
disposition: PolicyType
|
|
127
|
+
dkim: AuthResultType
|
|
128
|
+
spf: AuthResultType
|
|
129
|
+
reason: Optional[list[dict]] = None # type and comment. Not yet implemented
|
|
130
|
+
|
|
131
|
+
def __post_init__(self) -> None:
|
|
132
|
+
"""Convert string values to enums if needed."""
|
|
133
|
+
if isinstance(self.disposition, str):
|
|
134
|
+
self.disposition = PolicyType(self.disposition)
|
|
135
|
+
if isinstance(self.dkim, str):
|
|
136
|
+
self.dkim = AuthResultType(self.dkim)
|
|
137
|
+
if isinstance(self.spf, str):
|
|
138
|
+
self.spf = AuthResultType(self.spf)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Identifier:
|
|
143
|
+
"""Identifier object containing message header and envelope details.
|
|
144
|
+
|
|
145
|
+
The Identifier object corresponds to the <identifiers> element in the <record> element of DMARC XML report.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
header_from: str
|
|
149
|
+
envelope_to: Optional[str] = None
|
|
150
|
+
envelope_from: Optional[str] = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class DKIMAuthResult:
|
|
155
|
+
"""DKIM authentication result object.
|
|
156
|
+
|
|
157
|
+
The DKIMAuthResult object corresponds to the <dkim> element in the <auth_results> element of DMARC XML report.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
domain: str
|
|
161
|
+
result: AuthResultType
|
|
162
|
+
selector: Optional[str] = None
|
|
163
|
+
human_result: Optional[str] = None
|
|
164
|
+
|
|
165
|
+
def __post_init__(self) -> None:
|
|
166
|
+
"""Convert string values to enums if needed."""
|
|
167
|
+
if isinstance(self.result, str):
|
|
168
|
+
self.result = AuthResultType(self.result)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class SPFAuthResult:
|
|
173
|
+
"""SPF authentication result object.
|
|
174
|
+
|
|
175
|
+
The SPFAuthResult object corresponds to the <spf> element in the <auth_results> element of DMARC XML report.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
domain: str
|
|
179
|
+
result: AuthResultType
|
|
180
|
+
scope: Optional[str] = None
|
|
181
|
+
human_result: Optional[str] = None
|
|
182
|
+
|
|
183
|
+
VALID_SCOPES: ClassVar[set[str]] = {"helo", "mfrom"}
|
|
184
|
+
|
|
185
|
+
def __post_init__(self) -> None:
|
|
186
|
+
"""Convert string values to enums if needed and validate scope."""
|
|
187
|
+
if isinstance(self.result, str):
|
|
188
|
+
self.result = AuthResultType(self.result)
|
|
189
|
+
if self.scope and self.scope not in self.VALID_SCOPES:
|
|
190
|
+
msg = f"Invalid scope: {self.scope}. Must be one of {self.VALID_SCOPES}"
|
|
191
|
+
raise ValueError(msg)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class AuthResults:
|
|
196
|
+
"""Authentication results for DKIM and SPF.
|
|
197
|
+
|
|
198
|
+
The AuthResults object corresponds to the <auth_results> element in the DMARC XML report.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
dkim: list[DKIMAuthResult]
|
|
202
|
+
spf: list[SPFAuthResult]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class Record:
|
|
207
|
+
"""DMARC record object containing message details and authentication results.
|
|
208
|
+
|
|
209
|
+
The Record object corresponds to the <record> element in the DMARC XML report.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
source_ip: str
|
|
213
|
+
count: int
|
|
214
|
+
policy_evaluated: PolicyEvaluated
|
|
215
|
+
identifiers: Identifier
|
|
216
|
+
auth_results: AuthResults
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class Report:
|
|
221
|
+
"""DMARC report object containing metadata, policy, and records.
|
|
222
|
+
|
|
223
|
+
This class provides properties and methods to access and display the DMARC report data.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
report_metadata: ReportMetadata
|
|
227
|
+
policy_published: PolicyPublished
|
|
228
|
+
records: list[Record]
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def org_name(self) -> str:
|
|
232
|
+
"""Return the organization name of the report recipient."""
|
|
233
|
+
return self.report_metadata.org_name
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def email(self) -> str:
|
|
237
|
+
"""Return the email address of the report recipient."""
|
|
238
|
+
return self.report_metadata.email
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def report_id(self) -> str:
|
|
242
|
+
"""Return the report ID."""
|
|
243
|
+
return self.report_metadata.report_id
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def date_range(self) -> tuple[int, int]:
|
|
247
|
+
"""Return the date range of the DMARC report."""
|
|
248
|
+
return (self.report_metadata.date_range.begin, self.report_metadata.date_range.end)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def domain(self) -> str:
|
|
252
|
+
"""Return the domain of the DMARC report."""
|
|
253
|
+
return self.policy_published.domain
|
|
254
|
+
|
|
255
|
+
def get_summary_stats(self) -> dict:
|
|
256
|
+
"""Generate summary statistics for the report."""
|
|
257
|
+
total_messages = sum(record.count for record in self.records)
|
|
258
|
+
|
|
259
|
+
# Calculate pass rates
|
|
260
|
+
dkim_pass = sum(record.count for record in self.records if record.policy_evaluated.dkim == "pass")
|
|
261
|
+
spf_pass = sum(record.count for record in self.records if record.policy_evaluated.spf == "pass")
|
|
262
|
+
|
|
263
|
+
# Count dispositions
|
|
264
|
+
dispositions = {}
|
|
265
|
+
for record in self.records:
|
|
266
|
+
disp = record.policy_evaluated.disposition.value
|
|
267
|
+
dispositions[disp] = dispositions.get(disp, 0) + record.count
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"total_messages": total_messages,
|
|
271
|
+
"unique_sources": len({record.source_ip for record in self.records}),
|
|
272
|
+
"dkim_pass_rate": dkim_pass / total_messages if total_messages > 0 else 0,
|
|
273
|
+
"spf_pass_rate": spf_pass / total_messages if total_messages > 0 else 0,
|
|
274
|
+
"dispositions": dispositions,
|
|
275
|
+
"report_period": str(self.report_metadata.date_range),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
def display(self) -> None:
|
|
279
|
+
"""Display the DMARC report using Rich tables and panels."""
|
|
280
|
+
console = Console()
|
|
281
|
+
|
|
282
|
+
# Create header panel with today's date and time
|
|
283
|
+
header = f"""
|
|
284
|
+
[bold]Domain:[/bold] [green]{self.policy_published.domain}[/green]
|
|
285
|
+
[bold]Report Period:[/bold] [green]{self.report_metadata.date_range}[/green]
|
|
286
|
+
"""
|
|
287
|
+
console.print(
|
|
288
|
+
Panel(
|
|
289
|
+
header,
|
|
290
|
+
title=f"DMARC Report for {self.policy_published.domain}",
|
|
291
|
+
border_style="blue",
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Display summary statistics
|
|
296
|
+
stats = self.get_summary_stats()
|
|
297
|
+
stats_table = Table(title="Summary Statistics", box=box.ROUNDED, show_header=False)
|
|
298
|
+
stats_table.add_column("Metric", style="cyan")
|
|
299
|
+
stats_table.add_column("Value", style="green")
|
|
300
|
+
|
|
301
|
+
stats_table.add_row("Total Messages", str(stats["total_messages"]))
|
|
302
|
+
stats_table.add_row("Unique Sources", str(stats["unique_sources"]))
|
|
303
|
+
stats_table.add_row("DKIM Pass Rate", f"{stats['dkim_pass_rate']:.1%}")
|
|
304
|
+
stats_table.add_row("SPF Pass Rate", f"{stats['spf_pass_rate']:.1%}")
|
|
305
|
+
|
|
306
|
+
console.print(stats_table)
|
|
307
|
+
|
|
308
|
+
# Report Details table
|
|
309
|
+
details_table = Table(title="DMARC Report Metadata", box=box.ROUNDED, show_header=False)
|
|
310
|
+
details_table.add_column("Field", style="cyan")
|
|
311
|
+
details_table.add_column("Value", style="green")
|
|
312
|
+
details_table.add_row("Org name", self.report_metadata.org_name)
|
|
313
|
+
details_table.add_row("Email", self.report_metadata.email)
|
|
314
|
+
details_table.add_row("Extra contact info", self.report_metadata.extra_contact_info)
|
|
315
|
+
details_table.add_row("Report ID", self.report_metadata.report_id)
|
|
316
|
+
details_table.add_row("Date range", str(self.report_metadata.date_range))
|
|
317
|
+
|
|
318
|
+
console.print(details_table)
|
|
319
|
+
console.print()
|
|
320
|
+
|
|
321
|
+
# Create policy table
|
|
322
|
+
policy_table = Table(title="DMARC Policy Details", box=box.ROUNDED, show_header=False)
|
|
323
|
+
policy_table.add_column("Setting", style="cyan")
|
|
324
|
+
policy_table.add_column("Value", style="green")
|
|
325
|
+
|
|
326
|
+
policy_table.add_row("Domain", self.policy_published.domain)
|
|
327
|
+
policy_table.add_row("DKIM Alignment", self.policy_published.adkim.value)
|
|
328
|
+
policy_table.add_row("SPF Alignment", self.policy_published.aspf.value)
|
|
329
|
+
policy_table.add_row("Policy", self.policy_published.p.value)
|
|
330
|
+
policy_table.add_row("Subdomain Policy", self.policy_published.sp.value)
|
|
331
|
+
policy_table.add_row("Percent", str(self.policy_published.pct))
|
|
332
|
+
if self.policy_published.fo:
|
|
333
|
+
policy_table.add_row("Failure Options", self.policy_published.fo)
|
|
334
|
+
|
|
335
|
+
console.print(policy_table)
|
|
336
|
+
console.print()
|
|
337
|
+
|
|
338
|
+
# Create records table
|
|
339
|
+
records_table = Table(title="Message Records", box=box.ROUNDED)
|
|
340
|
+
records_table.add_column("Source IP", style="cyan")
|
|
341
|
+
records_table.add_column("Count", style="magenta")
|
|
342
|
+
records_table.add_column("Disposition", style="green")
|
|
343
|
+
records_table.add_column("DKIM", style="yellow")
|
|
344
|
+
records_table.add_column("SPF", style="yellow")
|
|
345
|
+
records_table.add_column("Header From", style="blue")
|
|
346
|
+
records_table.add_column("Auth Results", style="green")
|
|
347
|
+
|
|
348
|
+
for record in self.records:
|
|
349
|
+
dkim_auth_results_str = "\n".join(
|
|
350
|
+
f"dkim: {ar_d.domain} ({ar_d.result.value})" for ar_d in record.auth_results.dkim
|
|
351
|
+
)
|
|
352
|
+
spf_auth_results_str = "\n".join(
|
|
353
|
+
f"spf: {ar_s.domain} ({ar_s.result.value})" for ar_s in record.auth_results.spf
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
records_table.add_row(
|
|
357
|
+
record.source_ip,
|
|
358
|
+
str(record.count),
|
|
359
|
+
record.policy_evaluated.disposition.value,
|
|
360
|
+
record.policy_evaluated.dkim.value,
|
|
361
|
+
record.policy_evaluated.spf.value,
|
|
362
|
+
record.identifiers.header_from,
|
|
363
|
+
dkim_auth_results_str + "\n" + spf_auth_results_str,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
console.print(records_table)
|
|
367
|
+
|
|
368
|
+
console.print("\n :rocket: [bold]End of report[/bold]")
|
|
369
|
+
console.rule()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class DMARCParser:
|
|
373
|
+
"""Parse DMARC XML reports.
|
|
374
|
+
|
|
375
|
+
This class provides methods to parse DMARC XML reports from files and strings.
|
|
376
|
+
|
|
377
|
+
When used with the `parse_file` method, it can handle .xml, .xml.gz, and .zip file types, and will return a Report
|
|
378
|
+
object.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def parse_file(filepath: str) -> Report:
|
|
383
|
+
"""Parse a DMARC report file and return a Report object.
|
|
384
|
+
|
|
385
|
+
Handles .xml, .xml.gz, and .zip file types.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
filepath (str): Path to the DMARC report file.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Report: A Report object containing the parsed DMARC report data.
|
|
392
|
+
|
|
393
|
+
Raises:
|
|
394
|
+
ValueError: If the file type is not supported.
|
|
395
|
+
"""
|
|
396
|
+
filepath = Path(filepath)
|
|
397
|
+
suffix = filepath.suffix.lower()
|
|
398
|
+
|
|
399
|
+
if suffix == ".zip":
|
|
400
|
+
content = DMARCParser._read_zip(filepath)
|
|
401
|
+
elif suffix == ".gz":
|
|
402
|
+
content = DMARCParser._read_gzip(filepath)
|
|
403
|
+
elif suffix == ".xml":
|
|
404
|
+
content = DMARCParser._read_xml(filepath)
|
|
405
|
+
else:
|
|
406
|
+
msg = f"Unsupported file type: {filepath.suffix}"
|
|
407
|
+
raise ValueError(msg)
|
|
408
|
+
|
|
409
|
+
# Parse the content into a Report object
|
|
410
|
+
root = ElementTree.fromstring(content)
|
|
411
|
+
return DMARCParser.parse_xml(root)
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _read_zip(filepath: str) -> str:
|
|
415
|
+
"""Parse a zipped DMARC XML report file and return the content.
|
|
416
|
+
|
|
417
|
+
Looks for the first .xml file in the zip archive.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
filepath (str): Path to the zip archive containing the DMARC XML report.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
str: The content of the first XML file found in the zip archive.
|
|
424
|
+
|
|
425
|
+
Raises:
|
|
426
|
+
ValueError: If no XML file is found in the zip archive.
|
|
427
|
+
"""
|
|
428
|
+
with zipfile.ZipFile(filepath) as zip_file:
|
|
429
|
+
# Find the first XML file in the archive
|
|
430
|
+
xml_files = [f for f in zip_file.namelist() if f.lower().endswith(".xml")]
|
|
431
|
+
if not xml_files:
|
|
432
|
+
msg = f"No XML file found in zip archive: {filepath}"
|
|
433
|
+
raise ValueError(msg)
|
|
434
|
+
|
|
435
|
+
# Read the first XML file
|
|
436
|
+
with zip_file.open(xml_files[0]) as f:
|
|
437
|
+
return f.read().decode("utf-8")
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def _read_gzip(filepath: str) -> str:
|
|
441
|
+
"""Parse a gzipped DMARC XML report file and return the content.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
filepath (str): Path to the gzipped DMARC XML report file.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
str: The content of the gzipped XML file.
|
|
448
|
+
"""
|
|
449
|
+
with gzip.open(filepath, "rt", encoding="utf-8") as f:
|
|
450
|
+
return f.read()
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _read_xml(filepath: str) -> str:
|
|
454
|
+
"""Parse a DMARC XML report file and return the content.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
filepath (str): Path to the DMARC XML report file.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
str: The content of the XML file.
|
|
461
|
+
"""
|
|
462
|
+
with Path.open(filepath, encoding="utf-8") as f:
|
|
463
|
+
return f.read()
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def parse_xml(root: Element) -> Report:
|
|
467
|
+
"""Parse an XML ElementTree and return a Report object.
|
|
468
|
+
|
|
469
|
+
This is the main logic that parses the DMARC XML report.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
root (Element): The root of the XML ElementTree.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
Report: A Report object containing the parsed DMARC report data.
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
ValueError: If the XML structure is invalid or missing required fields.
|
|
479
|
+
"""
|
|
480
|
+
# Extract report metadata
|
|
481
|
+
report_metadata = root.find("report_metadata")
|
|
482
|
+
metadata = ReportMetadata(
|
|
483
|
+
org_name=report_metadata.findtext("org_name"),
|
|
484
|
+
email=report_metadata.findtext("email"),
|
|
485
|
+
report_id=report_metadata.findtext("report_id"),
|
|
486
|
+
date_range=DateRange(
|
|
487
|
+
begin=int(report_metadata.find("date_range").findtext("begin")),
|
|
488
|
+
end=int(report_metadata.find("date_range").findtext("end")),
|
|
489
|
+
),
|
|
490
|
+
extra_contact_info=report_metadata.findtext("extra_contact_info"),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Extract policy published
|
|
494
|
+
policy_published = root.find("policy_published")
|
|
495
|
+
policy = PolicyPublished(
|
|
496
|
+
domain=policy_published.findtext("domain"),
|
|
497
|
+
p=policy_published.findtext("p"),
|
|
498
|
+
sp=policy_published.findtext("sp", "none"),
|
|
499
|
+
pct=int(policy_published.findtext("pct", "100")),
|
|
500
|
+
adkim=policy_published.findtext("adkim", "r"),
|
|
501
|
+
aspf=policy_published.findtext("aspf", "r"),
|
|
502
|
+
fo=policy_published.findtext("fo"),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Extract records
|
|
506
|
+
records: list[Record] = []
|
|
507
|
+
for record in root.findall(".//record"):
|
|
508
|
+
# Parse authentication results
|
|
509
|
+
auth_results_elem = record.find("auth_results")
|
|
510
|
+
|
|
511
|
+
dkim_results_elem = auth_results_elem.findall("dkim")
|
|
512
|
+
dkim_auth_results = [
|
|
513
|
+
DKIMAuthResult(
|
|
514
|
+
domain=dkim_result.findtext("domain"),
|
|
515
|
+
result=dkim_result.findtext("result"),
|
|
516
|
+
selector=dkim_result.findtext("selector"),
|
|
517
|
+
human_result=dkim_result.findtext("human_result"),
|
|
518
|
+
)
|
|
519
|
+
for dkim_result in dkim_results_elem
|
|
520
|
+
]
|
|
521
|
+
|
|
522
|
+
spf_results_elem = auth_results_elem.findall("spf")
|
|
523
|
+
spf_auth_results = [
|
|
524
|
+
SPFAuthResult(
|
|
525
|
+
domain=spf_result.findtext("domain"),
|
|
526
|
+
result=spf_result.findtext("result"),
|
|
527
|
+
scope=spf_result.findtext("scope"),
|
|
528
|
+
human_result=spf_result.findtext("human_result"),
|
|
529
|
+
)
|
|
530
|
+
for spf_result in spf_results_elem
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
auth_results = AuthResults(
|
|
534
|
+
dkim=dkim_auth_results,
|
|
535
|
+
spf=spf_auth_results,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Create row object
|
|
539
|
+
row = Record(
|
|
540
|
+
source_ip=record.findtext(".//source_ip"),
|
|
541
|
+
count=int(record.findtext(".//count")),
|
|
542
|
+
policy_evaluated=PolicyEvaluated(
|
|
543
|
+
disposition=record.findtext(".//disposition"),
|
|
544
|
+
dkim=record.findtext(".//dkim"),
|
|
545
|
+
spf=record.findtext(".//spf"),
|
|
546
|
+
),
|
|
547
|
+
identifiers=Identifier(
|
|
548
|
+
header_from=record.findtext(".//identifier/header_from"),
|
|
549
|
+
envelope_from=record.findtext(".//identifier/envelope_from"),
|
|
550
|
+
envelope_to=record.findtext(".//identifier/envelope_to"),
|
|
551
|
+
),
|
|
552
|
+
auth_results=auth_results,
|
|
553
|
+
)
|
|
554
|
+
records.append(row)
|
|
555
|
+
|
|
556
|
+
return Report(
|
|
557
|
+
report_metadata=metadata,
|
|
558
|
+
policy_published=policy,
|
|
559
|
+
records=records,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
@staticmethod
|
|
563
|
+
def format_date_range(timestamp: int) -> str:
|
|
564
|
+
"""Convert UTC Unix timestamp to formatted UTC date string."""
|
|
565
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") # noqa: UP017
|
dmarc_report/report.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Command line interface for parsing and displaying DMARC reports."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from dmarc_report.dmarc_parser import DMARCParser
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def report() -> None:
|
|
13
|
+
"""Parse and display DMARC reports from the command line."""
|
|
14
|
+
parser = argparse.ArgumentParser(description="Parse and display DMARC reports")
|
|
15
|
+
parser.add_argument("filepath", type=str, help="Path to DMARC report file (.xml, .xml.gz, or .zip)")
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
|
|
18
|
+
console = Console(stderr=True)
|
|
19
|
+
filepath = Path(args.filepath)
|
|
20
|
+
|
|
21
|
+
if not filepath.exists():
|
|
22
|
+
console.print(f"[red]Error:[/red] File not found: {filepath}")
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
report = DMARCParser.parse_file(args.filepath)
|
|
27
|
+
report.display()
|
|
28
|
+
except Exception as e: # noqa: BLE001
|
|
29
|
+
console.print(f"[red]Error:[/red] Failed to process {filepath}: {e!s}")
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
report()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dmarc-report
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Displays a nicely formatted report in your terminal from a DMARC XML report.
|
|
5
|
+
Author-email: Stuart Maxwell <stuart@amanzi.nz>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: defusedxml>=0.7.1
|
|
8
|
+
Requires-Dist: rich>=13.9.4
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: nox>=2024.10.9; extra == 'test'
|
|
11
|
+
Requires-Dist: pytest>=8.3.4; extra == 'test'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# DMARC Report Tool
|
|
15
|
+
|
|
16
|
+
Displays a nicely formatted report in your terminal from a DMARC XML report.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
This works best when installed with `uvx` or `pipx`.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# With uvx:
|
|
24
|
+
uvx install dmarc-report
|
|
25
|
+
|
|
26
|
+
# Or with pipx:
|
|
27
|
+
|
|
28
|
+
pipx install dmarc-report
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Run the `dmarc-report` command-line utility followed by a DMARC report file.
|
|
34
|
+
The DMARC report can have one of the following file extensions:
|
|
35
|
+
|
|
36
|
+
- `.xml.gz`
|
|
37
|
+
- `.zip`
|
|
38
|
+
- `.xml`
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
dmarc-report long-dmarc-report-filename.xml.gz
|
|
42
|
+
# or
|
|
43
|
+
dmarc-report long-dmarc-report-filename.xml
|
|
44
|
+
# or
|
|
45
|
+
dmarc-report long-dmarc-report-filename.zip
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Note: if using a zip file, it will only parse the first xml file found in the zip file.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dmarc_report/__init__.py,sha256=7iOlJdYqe-00BSc9rSBmzqLJ2X8KFE0EJz31OFVBiRY,44
|
|
2
|
+
dmarc_report/dmarc_parser.py,sha256=dgcgxQQelA5XSu4NlmzGmZBWfg_ZIG0uFujy6mfees8,19512
|
|
3
|
+
dmarc_report/report.py,sha256=OxUWRRt7k1U1SsgfVZyfqfbOQ4thQt-N1HLEB2iTwgk,986
|
|
4
|
+
dmarc_report-0.1.0.dist-info/METADATA,sha256=GY0TRhkn-JDO6p7DYue5MAp4NsPfCbkagQohYFq9m7s,1122
|
|
5
|
+
dmarc_report-0.1.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
6
|
+
dmarc_report-0.1.0.dist-info/entry_points.txt,sha256=9vsq4RbYEzwVN7LvPndZRHN8ae-xftpBUtr1S9_Sx2g,60
|
|
7
|
+
dmarc_report-0.1.0.dist-info/licenses/LICENSE,sha256=qf3Fmk-FBH_G5vkcfb6KiOrJHhsO3SO0tGHD24ps5so,1071
|
|
8
|
+
dmarc_report-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Stuart Maxwell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|