dmarcator 0.4.1 → 0.5.0
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.
- checksums.yaml +4 -4
- data/ChangeLog.md +16 -0
- data/README.md +7 -1
- data/lib/dmarcator/parser.rb +40 -4
- data/lib/dmarcator/query.rb +8 -1
- data/lib/dmarcator/report/auth_result.rb +13 -4
- data/lib/dmarcator/report/metadata.rb +34 -7
- data/lib/dmarcator/report/policy.rb +20 -4
- data/lib/dmarcator/report/reason.rb +9 -2
- data/lib/dmarcator/report/record.rb +86 -14
- data/lib/dmarcator/report.rb +30 -3
- data/lib/dmarcator/version.rb +1 -1
- data/lib/dmarcator/xml_helper.rb +32 -8
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d37cadaa1defc7108fecde4077ce8a97cf50a6d3f654b78306a953ef95f66202
|
|
4
|
+
data.tar.gz: 9ae6e328b4c09f784fafca8d2b933dda7f152ae9f2156f17e5f4f4429784affd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 53e2957b0eeaa2741f7aee2a8b8f479f98c09f2cb78a8b3c1f5bb91ab625323aedde59162c7d90c22044e00d0ae7d9ed20a358b5c049f64a7ec09f9424736a7d
|
|
7
|
+
data.tar.gz: 9e6469b8b9d53a2d64a3f4963569f4d7564363c35e9805def960de165480b5fcef7458c6712cc7f38028522fcb5c93bb526a320a5c65886d0e2b45d1476caba9
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
### 0.5.0 / 2026-10-28
|
|
2
|
+
|
|
3
|
+
* Performance: extract each report record in a single pass over its children instead of one XPath query per field (roughly 3x faster `Report#records`).
|
|
4
|
+
* Performance: extract report metadata and policy in a single pass.
|
|
5
|
+
* Performance: build `Record#reasons` and `Record#auth_results` lazily, so callers that only read scalar fields skip roughly half of record construction.
|
|
6
|
+
* Performance: reuse one memoized parser and transform instance, making `Record.parse` roughly 30% faster.
|
|
7
|
+
* Add `DMARCator::Report.max_report_bytes` to configure the maximum accepted report size; parsing builds a DOM roughly 13x the input size, so lower it to bound memory.
|
|
8
|
+
* Report elements that appear more than once now consistently use the first value, matching prior behavior for records.
|
|
9
|
+
* Assign `policy_published` fields explicitly instead of by element name.
|
|
10
|
+
|
|
11
|
+
### 0.4.2 / 2026-10-28
|
|
12
|
+
|
|
13
|
+
* Do not raise when the loaded `resolv` version is not a valid gem version; an unparseable version is treated as unknown instead of failing at load time.
|
|
14
|
+
* Reject DMARC records larger than 4 KiB (`Parser::MAX_RECORD_BYTES`) before parsing to bound work on hostile input.
|
|
15
|
+
* Raise the documented `InvalidRecord`/`InvalidReport` for non-String input instead of `ArgumentError`, `TypeError`, or a silently empty report.
|
|
16
|
+
|
|
1
17
|
### 0.4.1 / 2026-10-28
|
|
2
18
|
|
|
3
19
|
* Accept a case-insensitive `mailto:` scheme in `rua`/`ruf` report URIs.
|
data/README.md
CHANGED
|
@@ -129,7 +129,13 @@ DMARC records and XML reports are treated as untrusted input:
|
|
|
129
129
|
|
|
130
130
|
* XML reports are parsed with network access disabled (`NONET`), so external
|
|
131
131
|
entities are never resolved or fetched.
|
|
132
|
-
* Reports larger than 25 MiB
|
|
132
|
+
* Reports larger than `DMARCator::Report.max_report_bytes` (25 MiB by
|
|
133
|
+
default) are rejected before parsing. Parsing builds a DOM roughly 13× the
|
|
134
|
+
input size, so lower the limit to bound memory:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
DMARCator::Report.max_report_bytes = 5 * 1024 * 1024
|
|
138
|
+
```
|
|
133
139
|
* A warning is emitted at load time if the bundled `resolv` gem is affected by
|
|
134
140
|
CVE-2026-80212/CVE-2026-80213.
|
|
135
141
|
* Domain names are validated before resolution (labels ≤ 63 octets, total ≤
|
data/lib/dmarcator/parser.rb
CHANGED
|
@@ -14,6 +14,11 @@ module DMARCator
|
|
|
14
14
|
#
|
|
15
15
|
class Parser < Parslet::Parser
|
|
16
16
|
|
|
17
|
+
# Maximum accepted DMARC record size in bytes. Real records are far
|
|
18
|
+
# smaller; the cap bounds parsing work on hostile input.
|
|
19
|
+
#
|
|
20
|
+
MAX_RECORD_BYTES = 4096
|
|
21
|
+
|
|
17
22
|
root :dmarc_record
|
|
18
23
|
|
|
19
24
|
rule(:dmarc_record) do
|
|
@@ -194,11 +199,19 @@ module DMARCator
|
|
|
194
199
|
# The Hash of tags within the record.
|
|
195
200
|
#
|
|
196
201
|
# @raise [InvalidRecord]
|
|
197
|
-
# The record is malformed, or (in strict
|
|
198
|
-
# or invalid tag value.
|
|
202
|
+
# The record is not a String, is malformed, is too large, or (in strict
|
|
203
|
+
# mode) contains an unknown tag or invalid tag value.
|
|
199
204
|
#
|
|
200
205
|
def self.parse(record, strict: false)
|
|
201
|
-
|
|
206
|
+
unless record.is_a?(String)
|
|
207
|
+
raise InvalidRecord, "DMARC record must be a String, got #{record.class}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
if record.bytesize > MAX_RECORD_BYTES
|
|
211
|
+
raise InvalidRecord, "DMARC record exceeds maximum size of #{MAX_RECORD_BYTES} bytes"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
tree = parser.parse(record)
|
|
202
215
|
|
|
203
216
|
if strict
|
|
204
217
|
tree.each do |node|
|
|
@@ -212,7 +225,7 @@ module DMARCator
|
|
|
212
225
|
end
|
|
213
226
|
end
|
|
214
227
|
|
|
215
|
-
tags =
|
|
228
|
+
tags = transform.apply(tree.reject { |node| ignored?(node) })
|
|
216
229
|
hash = {}
|
|
217
230
|
|
|
218
231
|
tags.each do |tag|
|
|
@@ -243,6 +256,29 @@ module DMARCator
|
|
|
243
256
|
node.is_a?(Hash) && (node.key?(:unknown_tag) || node.key?(:invalid_tag))
|
|
244
257
|
end
|
|
245
258
|
|
|
259
|
+
#
|
|
260
|
+
# The memoized parser instance. Parslet caches rules per instance, so
|
|
261
|
+
# reusing it avoids rebuilding the grammar on every call.
|
|
262
|
+
#
|
|
263
|
+
# @return [Parser]
|
|
264
|
+
#
|
|
265
|
+
# @api private
|
|
266
|
+
#
|
|
267
|
+
def self.parser
|
|
268
|
+
@parser ||= new
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
#
|
|
272
|
+
# The memoized transform instance.
|
|
273
|
+
#
|
|
274
|
+
# @return [Transform]
|
|
275
|
+
#
|
|
276
|
+
# @api private
|
|
277
|
+
#
|
|
278
|
+
def self.transform
|
|
279
|
+
@transform ||= Transform.new
|
|
280
|
+
end
|
|
281
|
+
|
|
246
282
|
#
|
|
247
283
|
# Validates the parsed tags.
|
|
248
284
|
#
|
data/lib/dmarcator/query.rb
CHANGED
|
@@ -40,8 +40,15 @@ module DMARCator
|
|
|
40
40
|
version = resolv_version
|
|
41
41
|
return false unless version
|
|
42
42
|
|
|
43
|
+
begin
|
|
44
|
+
parsed = Gem::Version.new(version)
|
|
45
|
+
rescue ArgumentError
|
|
46
|
+
# A non-semver version string means we cannot tell; do not warn.
|
|
47
|
+
return false
|
|
48
|
+
end
|
|
49
|
+
|
|
43
50
|
VULNERABLE_RESOLV.any? do |requirement|
|
|
44
|
-
requirement.satisfied_by?(
|
|
51
|
+
requirement.satisfied_by?(parsed)
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -17,10 +17,19 @@ module DMARCator
|
|
|
17
17
|
def initialize(node)
|
|
18
18
|
@node = node
|
|
19
19
|
self.type = node.name
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
|
|
21
|
+
node.element_children.each do |element|
|
|
22
|
+
case element.name
|
|
23
|
+
when 'domain'
|
|
24
|
+
self.domain ||= element.text
|
|
25
|
+
when 'result'
|
|
26
|
+
self.result ||= element.text
|
|
27
|
+
when 'selector'
|
|
28
|
+
self.selector ||= element.text
|
|
29
|
+
when 'scope'
|
|
30
|
+
self.scope ||= element.text
|
|
31
|
+
end
|
|
32
|
+
end
|
|
24
33
|
end
|
|
25
34
|
end
|
|
26
35
|
end
|
|
@@ -19,13 +19,40 @@ module DMARCator
|
|
|
19
19
|
|
|
20
20
|
def initialize(node)
|
|
21
21
|
@node = node
|
|
22
|
-
self.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
self.errors = []
|
|
23
|
+
|
|
24
|
+
metadata = node.at_xpath('feedback/report_metadata')
|
|
25
|
+
return unless metadata
|
|
26
|
+
|
|
27
|
+
metadata.element_children.each do |element|
|
|
28
|
+
case element.name
|
|
29
|
+
when 'org_name'
|
|
30
|
+
self.org_name ||= element.text
|
|
31
|
+
when 'email'
|
|
32
|
+
self.email ||= element.text
|
|
33
|
+
when 'extra_contact_info'
|
|
34
|
+
self.extra_contact_info ||= element.text
|
|
35
|
+
when 'report_id'
|
|
36
|
+
self.report_id ||= element.text
|
|
37
|
+
when 'error'
|
|
38
|
+
errors << element.text
|
|
39
|
+
when 'date_range'
|
|
40
|
+
parse_date_range(element)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def parse_date_range(date_range)
|
|
48
|
+
date_range.element_children.each do |element|
|
|
49
|
+
case element.name
|
|
50
|
+
when 'begin'
|
|
51
|
+
self.begin_at ||= parse_timestamp(element.text)
|
|
52
|
+
when 'end'
|
|
53
|
+
self.end_at ||= parse_timestamp(element.text)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
29
56
|
end
|
|
30
57
|
end
|
|
31
58
|
end
|
|
@@ -17,11 +17,27 @@ module DMARCator
|
|
|
17
17
|
def initialize(node)
|
|
18
18
|
@node = node
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
end
|
|
20
|
+
policy = node.at_xpath('feedback/policy_published')
|
|
21
|
+
return unless policy
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
policy.element_children.each do |element|
|
|
24
|
+
case element.name
|
|
25
|
+
when 'pct'
|
|
26
|
+
self.pct ||= parse_integer(element.text)
|
|
27
|
+
when 'domain'
|
|
28
|
+
self.domain ||= element.text
|
|
29
|
+
when 'adkim'
|
|
30
|
+
self.adkim ||= element.text
|
|
31
|
+
when 'aspf'
|
|
32
|
+
self.aspf ||= element.text
|
|
33
|
+
when 'p'
|
|
34
|
+
self.p ||= element.text
|
|
35
|
+
when 'sp'
|
|
36
|
+
self.sp ||= element.text
|
|
37
|
+
when 'fo'
|
|
38
|
+
self.fo ||= element.text
|
|
39
|
+
end
|
|
40
|
+
end
|
|
25
41
|
end
|
|
26
42
|
end
|
|
27
43
|
end
|
|
@@ -16,8 +16,15 @@ module DMARCator
|
|
|
16
16
|
|
|
17
17
|
def initialize(node)
|
|
18
18
|
@node = node
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
|
|
20
|
+
node.element_children.each do |element|
|
|
21
|
+
case element.name
|
|
22
|
+
when 'type'
|
|
23
|
+
self.type ||= element.text
|
|
24
|
+
when 'comment'
|
|
25
|
+
self.comment ||= element.text
|
|
26
|
+
end
|
|
27
|
+
end
|
|
21
28
|
end
|
|
22
29
|
end
|
|
23
30
|
end
|
|
@@ -14,27 +14,99 @@ module DMARCator
|
|
|
14
14
|
|
|
15
15
|
ATTRIBUTES = [
|
|
16
16
|
:source_ip, :count, :disposition, :dkim, :spf,
|
|
17
|
-
:envelope_to, :envelope_from, :header_from
|
|
18
|
-
:auth_results, :reasons
|
|
17
|
+
:envelope_to, :envelope_from, :header_from
|
|
19
18
|
]
|
|
20
19
|
|
|
21
20
|
attr_accessor(*ATTRIBUTES)
|
|
21
|
+
attr_writer :auth_results, :reasons
|
|
22
22
|
|
|
23
23
|
def initialize(node)
|
|
24
24
|
@node = node
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
@reason_nodes = []
|
|
26
|
+
@auth_result_nodes = []
|
|
27
|
+
|
|
28
|
+
node.element_children.each do |section|
|
|
29
|
+
case section.name
|
|
30
|
+
when 'row'
|
|
31
|
+
parse_row(section)
|
|
32
|
+
when 'identifiers'
|
|
33
|
+
parse_identifiers(section)
|
|
34
|
+
when 'auth_results'
|
|
35
|
+
collect_auth_results(section)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#
|
|
41
|
+
# The record's `reason` elements.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Reason>]
|
|
44
|
+
#
|
|
45
|
+
def reasons
|
|
46
|
+
return @reasons if defined?(@reasons)
|
|
47
|
+
|
|
48
|
+
@reasons = @reason_nodes.map { |reason_node| Reason.new(reason_node) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#
|
|
52
|
+
# The record's `auth_results` entries.
|
|
53
|
+
#
|
|
54
|
+
# @return [Array<AuthResult>]
|
|
55
|
+
#
|
|
56
|
+
def auth_results
|
|
57
|
+
return @auth_results if defined?(@auth_results)
|
|
58
|
+
|
|
59
|
+
@auth_results = @auth_result_nodes.map do |auth_result_node|
|
|
60
|
+
AuthResult.new(auth_result_node)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def parse_row(row)
|
|
67
|
+
row.element_children.each do |element|
|
|
68
|
+
case element.name
|
|
69
|
+
when 'source_ip'
|
|
70
|
+
self.source_ip ||= element.text
|
|
71
|
+
when 'count'
|
|
72
|
+
self.count ||= parse_integer(element.text)
|
|
73
|
+
when 'policy_evaluated'
|
|
74
|
+
parse_policy_evaluated(element)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_policy_evaluated(policy_evaluated)
|
|
80
|
+
policy_evaluated.element_children.each do |element|
|
|
81
|
+
case element.name
|
|
82
|
+
when 'disposition'
|
|
83
|
+
self.disposition ||= element.text
|
|
84
|
+
when 'dkim'
|
|
85
|
+
self.dkim ||= element.text
|
|
86
|
+
when 'spf'
|
|
87
|
+
self.spf ||= element.text
|
|
88
|
+
when 'reason'
|
|
89
|
+
@reason_nodes << element
|
|
90
|
+
end
|
|
32
91
|
end
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_identifiers(identifiers)
|
|
95
|
+
identifiers.element_children.each do |element|
|
|
96
|
+
case element.name
|
|
97
|
+
when 'envelope_to'
|
|
98
|
+
self.envelope_to ||= element.text
|
|
99
|
+
when 'envelope_from'
|
|
100
|
+
self.envelope_from ||= element.text
|
|
101
|
+
when 'header_from'
|
|
102
|
+
self.header_from ||= element.text
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def collect_auth_results(auth_results)
|
|
108
|
+
auth_results.element_children.each do |element|
|
|
109
|
+
@auth_result_nodes << element
|
|
38
110
|
end
|
|
39
111
|
end
|
|
40
112
|
end
|
data/lib/dmarcator/report.rb
CHANGED
|
@@ -20,20 +20,47 @@ module DMARCator
|
|
|
20
20
|
class Report
|
|
21
21
|
include XmlHelper
|
|
22
22
|
|
|
23
|
-
#
|
|
23
|
+
# Default maximum accepted report size in bytes (25 MiB).
|
|
24
24
|
MAX_REPORT_BYTES = 25 * 1024 * 1024
|
|
25
25
|
|
|
26
|
+
class << self
|
|
27
|
+
#
|
|
28
|
+
# The maximum accepted report size in bytes.
|
|
29
|
+
#
|
|
30
|
+
# Defaults to {MAX_REPORT_BYTES}. Parsing a report builds a DOM that is
|
|
31
|
+
# roughly 13x the input size, so lower this to bound memory for reports
|
|
32
|
+
# from untrusted sources.
|
|
33
|
+
#
|
|
34
|
+
# @return [Integer]
|
|
35
|
+
#
|
|
36
|
+
attr_writer :max_report_bytes
|
|
37
|
+
|
|
38
|
+
def max_report_bytes
|
|
39
|
+
@max_report_bytes || MAX_REPORT_BYTES
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
26
43
|
#
|
|
27
44
|
# Initializes the report from an XML string.
|
|
28
45
|
#
|
|
29
46
|
# @param [String] xml
|
|
30
47
|
# The raw DMARC XML report.
|
|
31
48
|
#
|
|
49
|
+
# @raise [InvalidReport]
|
|
50
|
+
# The report is not a String, exceeds {.max_report_bytes}, or is not
|
|
51
|
+
# well-formed XML.
|
|
52
|
+
#
|
|
32
53
|
def initialize(xml)
|
|
54
|
+
unless xml.is_a?(String)
|
|
55
|
+
raise InvalidReport, "DMARC XML report must be a String, got #{xml.class}"
|
|
56
|
+
end
|
|
57
|
+
|
|
33
58
|
@xml = xml
|
|
34
59
|
|
|
35
|
-
|
|
36
|
-
|
|
60
|
+
limit = self.class.max_report_bytes
|
|
61
|
+
|
|
62
|
+
if xml.bytesize > limit
|
|
63
|
+
raise InvalidReport, "DMARC XML report exceeds maximum size of #{limit} bytes"
|
|
37
64
|
end
|
|
38
65
|
|
|
39
66
|
@node = Nokogiri::XML(xml) { |config| config.nonet }
|
data/lib/dmarcator/version.rb
CHANGED
data/lib/dmarcator/xml_helper.rb
CHANGED
|
@@ -29,13 +29,7 @@ module DMARCator
|
|
|
29
29
|
# @return [Time, nil]
|
|
30
30
|
#
|
|
31
31
|
def get_timestamp(xpath)
|
|
32
|
-
|
|
33
|
-
return nil if value.nil? || value.empty?
|
|
34
|
-
|
|
35
|
-
digits = value.strip
|
|
36
|
-
return nil unless digits.match?(/\A\d+\z/)
|
|
37
|
-
|
|
38
|
-
Time.at(digits.to_i)
|
|
32
|
+
parse_timestamp(get_text(xpath))
|
|
39
33
|
end
|
|
40
34
|
|
|
41
35
|
#
|
|
@@ -47,7 +41,37 @@ module DMARCator
|
|
|
47
41
|
# @return [Integer, nil]
|
|
48
42
|
#
|
|
49
43
|
def get_int(xpath)
|
|
50
|
-
|
|
44
|
+
parse_integer(get_text(xpath))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#
|
|
48
|
+
# Parses a Unix timestamp from raw element text.
|
|
49
|
+
#
|
|
50
|
+
# @param [String, nil] value
|
|
51
|
+
# The element text.
|
|
52
|
+
#
|
|
53
|
+
# @return [Time, nil]
|
|
54
|
+
#
|
|
55
|
+
# @api private
|
|
56
|
+
#
|
|
57
|
+
def parse_timestamp(value)
|
|
58
|
+
seconds = parse_integer(value)
|
|
59
|
+
|
|
60
|
+
Time.at(seconds) if seconds
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#
|
|
64
|
+
# Parses an integer from raw element text, returning `nil` for values that
|
|
65
|
+
# are not plain decimal digits.
|
|
66
|
+
#
|
|
67
|
+
# @param [String, nil] value
|
|
68
|
+
# The element text.
|
|
69
|
+
#
|
|
70
|
+
# @return [Integer, nil]
|
|
71
|
+
#
|
|
72
|
+
# @api private
|
|
73
|
+
#
|
|
74
|
+
def parse_integer(value)
|
|
51
75
|
return nil if value.nil? || value.empty?
|
|
52
76
|
|
|
53
77
|
digits = value.strip
|