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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d8d5d903f5d6ec715a2cd5042610d7f28f730981f460baceee5abbc4d6eb916
4
- data.tar.gz: d6ebe47854a74d046cf47b720ce60cc0598c83d5b529ed665951d45012a504fc
3
+ metadata.gz: d37cadaa1defc7108fecde4077ce8a97cf50a6d3f654b78306a953ef95f66202
4
+ data.tar.gz: 9ae6e328b4c09f784fafca8d2b933dda7f152ae9f2156f17e5f4f4429784affd
5
5
  SHA512:
6
- metadata.gz: b7ea01f6b847ccbb11bb6e4e1ead4c972a0c12e404631c462a8d4b249ca7622c7345b17bfbfd1651807fdd3ce269fdb6a0774ee9c8cb9e2d85cec6e7e55d871d
7
- data.tar.gz: 702597e80f58d060b1a0c6ef334697fa008ba9e0fad70d1096c6b71933901edf68643aa7234195507a12c2011624d5402cb6bc9fb1c7bbbbcb55e7bc914128a6
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 are rejected before parsing.
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 ≤
@@ -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 mode) contains an unknown tag
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
- tree = new.parse(record)
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 = Transform.new.apply(tree.reject { |node| ignored?(node) })
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
  #
@@ -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?(Gem::Version.new(version))
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
- self.domain = get_text('domain')
21
- self.result = get_text('result')
22
- self.selector = get_text('selector')
23
- self.scope = get_text('scope')
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.org_name = get_text('feedback/report_metadata/org_name')
23
- self.email = get_text('feedback/report_metadata/email')
24
- self.extra_contact_info = get_text('feedback/report_metadata/extra_contact_info')
25
- self.report_id = get_text('feedback/report_metadata/report_id')
26
- self.begin_at = get_timestamp('feedback/report_metadata/date_range/begin')
27
- self.end_at = get_timestamp('feedback/report_metadata/date_range/end')
28
- self.errors = @node.xpath('feedback/report_metadata/error').map(&:text)
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
- ATTRIBUTES.each do |attr|
21
- self.public_send("#{attr}=", get_text("feedback/policy_published/#{attr}"))
22
- end
20
+ policy = node.at_xpath('feedback/policy_published')
21
+ return unless policy
23
22
 
24
- self.pct = get_int('feedback/policy_published/pct')
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
- self.type = get_text('type')
20
- self.comment = get_text('comment')
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
- self.source_ip = get_text('row/source_ip')
26
- self.count = get_int('row/count')
27
- self.disposition = get_text('row/policy_evaluated/disposition')
28
- self.dkim = get_text('row/policy_evaluated/dkim')
29
- self.spf = get_text('row/policy_evaluated/spf')
30
- self.reasons = node.xpath('row/policy_evaluated/reason').map do |reason_node|
31
- Reason.new(reason_node)
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
- self.envelope_to = get_text('identifiers/envelope_to')
34
- self.envelope_from = get_text('identifiers/envelope_from')
35
- self.header_from = get_text('identifiers/header_from')
36
- self.auth_results = node.xpath('auth_results/*').map do |auth_results_node|
37
- AuthResult.new(auth_results_node)
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
@@ -20,20 +20,47 @@ module DMARCator
20
20
  class Report
21
21
  include XmlHelper
22
22
 
23
- # Maximum accepted report size in bytes (25 MiB).
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
- if xml.is_a?(String) && xml.bytesize > MAX_REPORT_BYTES
36
- raise InvalidReport, "DMARC XML report exceeds maximum size of #{MAX_REPORT_BYTES} bytes"
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 }
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DMARCator
4
4
  # dmarcator version
5
- VERSION = '0.4.1'
5
+ VERSION = '0.5.0'
6
6
  end
@@ -29,13 +29,7 @@ module DMARCator
29
29
  # @return [Time, nil]
30
30
  #
31
31
  def get_timestamp(xpath)
32
- value = get_text(xpath)
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
- value = get_text(xpath)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dmarcator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman