dmarcator 0.4.2 → 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 +10 -0
- data/README.md +7 -1
- data/lib/dmarcator/parser.rb +25 -2
- 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 +23 -4
- 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,13 @@
|
|
|
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
|
+
|
|
1
11
|
### 0.4.2 / 2026-10-28
|
|
2
12
|
|
|
3
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.
|
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
|
@@ -211,7 +211,7 @@ module DMARCator
|
|
|
211
211
|
raise InvalidRecord, "DMARC record exceeds maximum size of #{MAX_RECORD_BYTES} bytes"
|
|
212
212
|
end
|
|
213
213
|
|
|
214
|
-
tree =
|
|
214
|
+
tree = parser.parse(record)
|
|
215
215
|
|
|
216
216
|
if strict
|
|
217
217
|
tree.each do |node|
|
|
@@ -225,7 +225,7 @@ module DMARCator
|
|
|
225
225
|
end
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
-
tags =
|
|
228
|
+
tags = transform.apply(tree.reject { |node| ignored?(node) })
|
|
229
229
|
hash = {}
|
|
230
230
|
|
|
231
231
|
tags.each do |tag|
|
|
@@ -256,6 +256,29 @@ module DMARCator
|
|
|
256
256
|
node.is_a?(Hash) && (node.key?(:unknown_tag) || node.key?(:invalid_tag))
|
|
257
257
|
end
|
|
258
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
|
+
|
|
259
282
|
#
|
|
260
283
|
# Validates the parsed tags.
|
|
261
284
|
#
|
|
@@ -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,9 +20,26 @@ 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
|
#
|
|
@@ -30,7 +47,7 @@ module DMARCator
|
|
|
30
47
|
# The raw DMARC XML report.
|
|
31
48
|
#
|
|
32
49
|
# @raise [InvalidReport]
|
|
33
|
-
# The report is not a String, exceeds {
|
|
50
|
+
# The report is not a String, exceeds {.max_report_bytes}, or is not
|
|
34
51
|
# well-formed XML.
|
|
35
52
|
#
|
|
36
53
|
def initialize(xml)
|
|
@@ -40,8 +57,10 @@ module DMARCator
|
|
|
40
57
|
|
|
41
58
|
@xml = xml
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
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"
|
|
45
64
|
end
|
|
46
65
|
|
|
47
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
|