dmarcator 0.4.2 → 0.5.1

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: bf9c3559d1b305ece3b68de7e2e0eb72f1371be508b7499ce34160e543cbd6a1
4
- data.tar.gz: f082d761778abd9e66e8bed82cee7a3aafc498567814021120a5780dc9c5f5fc
3
+ metadata.gz: d474c30401fa6eddca63e996bfbc7bf2726930386282d12aad8fbbe3f67353bf
4
+ data.tar.gz: eca8a030b0b6abf0a2ceeaa9c446843f502ec8132f4d409c386e639806e7db69
5
5
  SHA512:
6
- metadata.gz: 85da1dd8330918fb7ae6c167a9c08b23c97589f5fb3d8586c7866e64d19cd9bdf04a39efdfa161be70c01ef276cadc3b907e04b35df3ed10f9c32ffd28eb2be8
7
- data.tar.gz: 4ed5b91fa75ba6eda666357a1541e27b11d008c5ec952f9573cf3a89243877a9757d09b6ae7d0488bb449b6c6dc798ed594d9ab75de1d911490e86bcda82a041
6
+ metadata.gz: 0c20cb2c3576c6acce7619b5c20ac0ea3b335a0ae14c6e3f1cb0876f09eb232d559dad8ce9197923c1c0b33828ed1c8e3d63437c7ddc1670828bad8ae0fdf8cf
7
+ data.tar.gz: bf4fac1cb1c98fcd597424ad913b1e2eb02e5e1fb93f985b21c4a5cbbc8fae87b29aaa83ff73b6edc2c9c40f468be712554a3c6d61ff61e592cbe215d0188805
data/ChangeLog.md CHANGED
@@ -1,3 +1,17 @@
1
+ ### 0.5.1 / 2026-10-28
2
+
3
+ * Point the gem metadata (`homepage`, `source_code_uri`, `bug_tracker_uri`) at the renamed GitHub repository (`dmarc` → `dmarcator`).
4
+
5
+ ### 0.5.0 / 2026-10-28
6
+
7
+ * Performance: extract each report record in a single pass over its children instead of one XPath query per field (roughly 3x faster `Report#records`).
8
+ * Performance: extract report metadata and policy in a single pass.
9
+ * Performance: build `Record#reasons` and `Record#auth_results` lazily, so callers that only read scalar fields skip roughly half of record construction.
10
+ * Performance: reuse one memoized parser and transform instance, making `Record.parse` roughly 30% faster.
11
+ * 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.
12
+ * Report elements that appear more than once now consistently use the first value, matching prior behavior for records.
13
+ * Assign `policy_published` fields explicitly instead of by element name.
14
+
1
15
  ### 0.4.2 / 2026-10-28
2
16
 
3
17
  * 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 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 ≤
@@ -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 = new.parse(record)
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 = Transform.new.apply(tree.reject { |node| ignored?(node) })
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
- 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,9 +20,26 @@ 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
  #
@@ -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 {MAX_REPORT_BYTES}, or is not
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
- if xml.bytesize > MAX_REPORT_BYTES
44
- 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"
45
64
  end
46
65
 
47
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.2'
5
+ VERSION = '0.5.1'
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.2
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman
@@ -82,12 +82,12 @@ files:
82
82
  - lib/dmarcator/uri.rb
83
83
  - lib/dmarcator/version.rb
84
84
  - lib/dmarcator/xml_helper.rb
85
- homepage: https://github.com/bermannoah/dmarc
85
+ homepage: https://github.com/bermannoah/dmarcator
86
86
  licenses:
87
87
  - MIT
88
88
  metadata:
89
- source_code_uri: https://github.com/bermannoah/dmarc
90
- bug_tracker_uri: https://github.com/bermannoah/dmarc/issues
89
+ source_code_uri: https://github.com/bermannoah/dmarcator
90
+ bug_tracker_uri: https://github.com/bermannoah/dmarcator/issues
91
91
  rubygems_mfa_required: 'true'
92
92
  rdoc_options: []
93
93
  require_paths: