dmarcator 0.12.0 → 1.0.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: b6c289ca56b406c5f591f414ada16407028e732310ad9b4c784e2f231469828a
4
- data.tar.gz: 24f260da56f98e045d262bd5fcbe3ee9d3f8be1b1bdd310e26e8b9b4b74f1021
3
+ metadata.gz: 6f88c01ff39e839792769a66063bd78cd312f51d5fba6917acf6777d034e0269
4
+ data.tar.gz: 9b39f980958757f2a27718d157ad9925424a20da358a7100d3b87f9a3765af5f
5
5
  SHA512:
6
- metadata.gz: f88871eab61d67fcccbe7ac0e6f0dfc8f97160592d97e69e58a69d9d99b069ec02e70b9126a0dc7ae114c9e50d599a0ca014ba9b02fc2514fab7db97c4bd4a08
7
- data.tar.gz: 2d10f64bd4cd096f54ba2a6372a07c582dc3aad581fe556a3228be4378ba09bd9e3e5ba57c94a4cd98acc4b87899ded063ab9b886b73390267659754713297bb
6
+ metadata.gz: 4c5aa75a034fedffc21ad9100de83b82f049dc08416f66cdcf2056ec6f76a8f28415ccfee012ea6432d4cbbbd760279c5a0dbce32d614f0932a0d8e9cd9efa3e
7
+ data.tar.gz: c1351578b215094ca74bb710725f2b66ad49f8e1c24ba14862ee32bc11099163f0d0e8cbb0736f5d23a614b6d1b801ea4fa9cd52e0a3f521f13ffffa21959897
data/ChangeLog.md CHANGED
@@ -1,3 +1,14 @@
1
+ ### 1.0.0 / 2026-10-30
2
+
3
+ The public API is now frozen.
4
+
5
+ * The contract spec added in 0.12.0 defines the supported surface: the module methods, constants and per-class public methods it asserts. Anything documented as `@api private` may still change in a minor release.
6
+ * Deprecations are documentation-only, and removals happen only in a major release. `Report.max_report_bytes`, `Report.max_report_bytes=` and `Report::MAX_REPORT_BYTES` are deprecated in favour of `DMARCator.configure` and `Config::DEFAULT_MAX_REPORT_BYTES`, and will be removed in 2.0.
7
+ * Supported Ruby is `>= 3.3.13`. Dropping a Ruby series is the one documented exception to the no-breaking-changes promise: it is announced in a minor release and removed in the next major.
8
+ * Parsed report values are now frozen, not merely reader-only: the strings taken from the document and the arrays holding records, errors, reasons, auth results and the parsed record's `rua`/`ruf`/`fo` cannot be mutated in place. `Record` and the report value objects are therefore safe to share between threads.
9
+ * The parser and XML helper moved to `DMARCator::Internal`, so the public constants are only documented API and the parser can be replaced in a minor release.
10
+ * Parsing, lookup and transport behaviour are unchanged from 0.12.0; the differences above are the freeze itself.
11
+
1
12
  ### 0.12.0 / 2026-10-29
2
13
 
3
14
  Breaking changes, taken before 1.0:
data/README.md CHANGED
@@ -312,6 +312,13 @@ A method is deprecated in a minor release by documentation only — no runtime
312
312
  warnings, so nothing is added to application logs. Deprecated methods are
313
313
  removed in the next major release.
314
314
 
315
+ ### Ruby support
316
+
317
+ The minimum is Ruby 3.3.13, the first release in that series with the regular
318
+ expression fixes the library requires. Dropping support for a Ruby series is
319
+ the one documented exception to the no-breaking-changes promise: it is announced
320
+ in a minor release and removed in the next major.
321
+
315
322
  ## Security
316
323
 
317
324
  DMARC records and XML reports are treated as untrusted input. See
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../exceptions'
4
+ require_relative '../uri'
5
+
6
+ require 'parslet'
7
+ require 'uri'
8
+
9
+ module DMARCator
10
+ #
11
+ # Implementation details of the library. Nothing here is covered by the
12
+ # 1.0 compatibility promise.
13
+ #
14
+ # @api private
15
+ #
16
+ module Internal
17
+ #
18
+ # DMARC parser.
19
+ #
20
+ # An implementation detail: use {Record.parse} rather than this class.
21
+ #
22
+ # @api private
23
+ #
24
+ # @see https://tools.ietf.org/html/rfc7489#section-6.4
25
+ #
26
+ class Parser < Parslet::Parser
27
+
28
+ # Maximum accepted DMARC record size in bytes. Real records are far
29
+ # smaller; the cap bounds parsing work on hostile input.
30
+ #
31
+ MAX_RECORD_BYTES = 4096
32
+
33
+ root :dmarc_record
34
+
35
+ rule(:dmarc_record) do
36
+ dmarc_version.repeat(1,1) >>
37
+ (dmarc_sep >> dmarc_tag).repeat >>
38
+ dmarc_sep.maybe >>
39
+ wsp?.maybe
40
+ end
41
+
42
+ rule(:dmarc_sep) { wsp? >> str(';') >> wsp? }
43
+
44
+ rule(:dmarc_version) do
45
+ str('v') >> wsp? >>
46
+ str('=') >> wsp? >>
47
+ str('DMARC1').as(:v)
48
+ end
49
+
50
+ rule(:dmarc_tag) do
51
+ dmarc_request |
52
+ dmarc_srequest |
53
+ dmarc_auri |
54
+ dmarc_furi |
55
+ dmarc_adkim |
56
+ dmarc_aspf |
57
+ dmarc_ainterval |
58
+ dmarc_fo |
59
+ dmarc_rfmt |
60
+ dmarc_percent |
61
+ unknown_tag
62
+ end
63
+
64
+ #
65
+ # Defines a rule for a `tag=value` DMARC tag, capturing values that do not
66
+ # match the rule as `:invalid_tag` so strict mode can reject them.
67
+ #
68
+ # @param [Symbol] name
69
+ # The rule name, used as `dmarc_<name>`.
70
+ #
71
+ # @param [String] tag
72
+ # The DMARC tag name.
73
+ #
74
+ # @yieldreturn [Parslet::Atoms::Base]
75
+ # The parslet matching the tag's value.
76
+ #
77
+ # @api private
78
+ #
79
+ def self.tag_rule(name,tag,&block)
80
+ rule(:"dmarc_#{name}") do
81
+ (
82
+ str(tag) >> wsp? >> str('=') >> wsp? >>
83
+ instance_eval(&block).as(tag.to_sym)
84
+ ) |
85
+ (
86
+ str(tag) >> wsp? >> str('=') >> wsp? >>
87
+ match["^; \t"].repeat(1)
88
+ ).as(:invalid_tag)
89
+ end
90
+ end
91
+
92
+ tag_rule(:request,'p') do
93
+ str('none') | str('quarantine') | str('reject')
94
+ end
95
+
96
+ tag_rule(:srequest,'sp') do
97
+ str('none') | str('quarantine') | str('reject')
98
+ end
99
+
100
+ tag_rule(:auri, 'rua') do
101
+ dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
102
+ end
103
+
104
+ tag_rule(:ainterval,'ri') { digit.repeat(1, 10) >> digit.absent? }
105
+
106
+ tag_rule(:furi,'ruf') do
107
+ dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
108
+ end
109
+
110
+ tag_rule(:fo,'fo') do
111
+ fo_opt >> (wsp? >> str(':') >> wsp? >> fo_opt).repeat
112
+ end
113
+
114
+ rule(:fo_opt) { match['01ds'].as(:fo_opt) }
115
+
116
+ tag_rule(:rfmt,'rf') { str('afrf') | str('iodef') }
117
+
118
+ tag_rule(:percent,'pct') { digit.repeat(1,3) >> digit.absent? }
119
+
120
+ tag_rule(:adkim, 'adkim') { match['rs'] }
121
+ tag_rule(:aspf, 'aspf') { match['rs'] }
122
+
123
+ rule(:unknown_tag) { match["^; \t"].repeat(1).as(:unknown_tag) }
124
+
125
+ rule(:dmarc_uri) do
126
+ mailto_uri.as(:uri) >> (
127
+ str('!') >> digit.repeat(1, 10).as(:size) >> digit.absent? >> (
128
+ match['kmgt']
129
+ ).as(:unit).maybe
130
+ ).maybe >> str('!').absent?
131
+ end
132
+
133
+ rule(:uri_list_sep) { wsp? >> str(',') >> wsp? >> dmarc_uri }
134
+
135
+ rule(:mailto_uri) do
136
+ match['mM'] >> match['aA'] >> match['iI'] >> match['lL'] >> match['tT'] >> match['oO'] >>
137
+ str(':') >> mailto_address
138
+ end
139
+
140
+ rule(:mailto_address) do
141
+ mailto_local >> str('@') >> mailto_domain
142
+ end
143
+
144
+ rule(:mailto_local) { mailto_char.repeat(1) }
145
+ rule(:mailto_domain) { mailto_char.repeat(1) }
146
+ rule(:mailto_char) { match["A-Za-z0-9._%+-"] }
147
+
148
+ rule(:digit) { match('[0-9]') }
149
+ rule(:wsp) { str(' ') | str("\t") }
150
+ rule(:wsp?) { wsp.repeat }
151
+
152
+ #
153
+ # Converts the raw parse tree into tag attributes.
154
+ #
155
+ # @api private
156
+ #
157
+ class Transform < Parslet::Transform
158
+
159
+ rule(fo_opt: simple(:fo_opt)) { fo_opt }
160
+ rule(fo: simple(:fo_opt)) { {fo: [fo_opt]} }
161
+
162
+ rule(v: simple(:version)) { {v: version.to_sym} }
163
+ rule(p: simple(:p)) { {p: p.to_sym } }
164
+ rule(sp: simple(:sp)) { {sp: sp.to_sym} }
165
+ rule(rf: simple(:rf)) { {rf: rf.to_sym} }
166
+ rule(adkim: simple(:adkim)) { {adkim: adkim.to_sym} }
167
+ rule(aspf: simple(:aspf)) { {aspf: aspf.to_sym} }
168
+
169
+ rule(pct: simple(:pct)) { {pct: pct.to_i} }
170
+ rule(ri: simple(:ri)) { {ri: ri.to_i} }
171
+
172
+ rule(uri: simple(:uri), size: simple(:size), unit: simple(:unit)) do
173
+ Uri.new(
174
+ Transform.parse_uri(uri),
175
+ size.to_i,
176
+ unit.to_sym
177
+ )
178
+ end
179
+ rule(uri: simple(:uri), size: simple(:size)) do
180
+ Uri.new(
181
+ Transform.parse_uri(uri),
182
+ size.to_i
183
+ )
184
+ end
185
+ rule(uri: simple(:uri)) { Uri.new(Transform.parse_uri(uri)) }
186
+ rule(rua: subtree(:uris)) { {rua: Array(uris)} }
187
+ rule(ruf: subtree(:uris)) { {ruf: Array(uris)} }
188
+
189
+ # Maximum length of error messages echoed from untrusted input.
190
+ MAX_ERROR_MESSAGE_LENGTH = 300
191
+
192
+ #
193
+ # Parses a `mailto:` URI from a parse tree slice.
194
+ #
195
+ # @param [Parslet::Slice] value
196
+ #
197
+ # @return [URI::MailTo]
198
+ #
199
+ # @raise [InvalidRecord]
200
+ # The URI is not a valid `mailto:` URI.
201
+ #
202
+ # @api private
203
+ #
204
+ def self.parse_uri(value)
205
+ uri = value.to_s
206
+
207
+ begin
208
+ parsed = URI.parse(uri)
209
+ rescue URI::Error => error
210
+ raise InvalidRecord, truncate(error.message)
211
+ end
212
+
213
+ unless parsed.is_a?(URI::MailTo)
214
+ raise InvalidRecord, "invalid DMARC report URI (expected mailto): #{truncate(uri)}"
215
+ end
216
+
217
+ parsed
218
+ end
219
+
220
+ #
221
+ # Truncates an error message to a bounded length.
222
+ #
223
+ # @param [String] message
224
+ #
225
+ # @return [String]
226
+ #
227
+ # @api private
228
+ #
229
+ def self.truncate(message)
230
+ message = message.to_s
231
+
232
+ if message.length > MAX_ERROR_MESSAGE_LENGTH
233
+ message[0, MAX_ERROR_MESSAGE_LENGTH] + "..."
234
+ else
235
+ message
236
+ end
237
+ end
238
+
239
+ end
240
+
241
+ #
242
+ # Parses a DMARC record.
243
+ #
244
+ # @param [String] record
245
+ # The raw DMARC record to parse.
246
+ #
247
+ # @param [Boolean] strict (false)
248
+ # When `true`, raise on unknown tags or invalid tag values instead of
249
+ # silently ignoring them.
250
+ #
251
+ # @return [Hash{Symbol => Object}]
252
+ # The Hash of tags within the record.
253
+ #
254
+ # @raise [InvalidRecord]
255
+ # The record is not a String, is malformed, is too large, or (in strict
256
+ # mode) contains an unknown tag or invalid tag value.
257
+ #
258
+ def self.parse(record, strict: false)
259
+ unless record.is_a?(String)
260
+ raise InvalidRecord, "DMARC record must be a String, got #{record.class}"
261
+ end
262
+
263
+ if record.bytesize > MAX_RECORD_BYTES
264
+ raise InvalidRecord, "DMARC record exceeds maximum size of #{MAX_RECORD_BYTES} bytes"
265
+ end
266
+
267
+ tree = parser.parse(record)
268
+
269
+ if strict
270
+ tree.each do |node|
271
+ next unless ignored?(node)
272
+
273
+ if node.key?(:unknown_tag)
274
+ raise InvalidRecord, "unknown DMARC tag: #{Transform.truncate(node[:unknown_tag].to_s).inspect}"
275
+ else
276
+ raise InvalidRecord, "invalid DMARC tag value: #{Transform.truncate(node[:invalid_tag].to_s).inspect}"
277
+ end
278
+ end
279
+ end
280
+
281
+ tags = transform.apply(tree.reject { |node| ignored?(node) })
282
+ hash = {}
283
+
284
+ tags.each do |tag|
285
+ tag.each_key do |key|
286
+ if hash.key?(key)
287
+ raise InvalidRecord, "duplicate DMARC tag: #{key}"
288
+ end
289
+ end
290
+
291
+ hash.merge!(tag)
292
+ end
293
+
294
+ validate!(hash)
295
+
296
+ return hash
297
+ end
298
+
299
+ #
300
+ # Determines whether a parse-tree node represents a tag that the lenient
301
+ # parser dropped because it was unknown or had an invalid value.
302
+ #
303
+ # @param [Object] node
304
+ # The parse-tree node.
305
+ #
306
+ # @return [Boolean]
307
+ #
308
+ def self.ignored?(node)
309
+ node.is_a?(Hash) && (node.key?(:unknown_tag) || node.key?(:invalid_tag))
310
+ end
311
+
312
+ #
313
+ # The memoized parser instance. Parslet caches rules per instance, so
314
+ # reusing it avoids rebuilding the grammar on every call.
315
+ #
316
+ # @return [Parser]
317
+ #
318
+ # @api private
319
+ #
320
+ def self.parser
321
+ @parser ||= new
322
+ end
323
+
324
+ #
325
+ # The memoized transform instance.
326
+ #
327
+ # @return [Transform]
328
+ #
329
+ # @api private
330
+ #
331
+ def self.transform
332
+ @transform ||= Transform.new
333
+ end
334
+
335
+ #
336
+ # Validates the parsed tags.
337
+ #
338
+ # @param [Hash{Symbol => Object}] tags
339
+ # The parsed tags.
340
+ #
341
+ # @raise [InvalidRecord]
342
+ # One of the tags has an invalid value.
343
+ #
344
+ def self.validate!(tags)
345
+ if tags[:pct] && !(tags[:pct].is_a?(Integer) && tags[:pct].between?(0, 100))
346
+ raise InvalidRecord, "pct must be an integer between 0 and 100"
347
+ end
348
+
349
+ if tags[:ri] && !(tags[:ri].is_a?(Integer) && tags[:ri].between?(0, 2**32 - 1))
350
+ raise InvalidRecord, "ri must be an integer between 0 and 4294967295"
351
+ end
352
+ end
353
+
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module DMARCator
6
+ #
7
+ # Implementation details of the library. Nothing here is covered by the
8
+ # 1.0 compatibility promise.
9
+ #
10
+ # @api private
11
+ #
12
+ module Internal
13
+ #
14
+ # Internal helper for extracting values from parsed DMARC XML reports.
15
+ #
16
+ module XmlHelper
17
+ private
18
+
19
+ #
20
+ # Returns the text of the first node matching the XPath expression.
21
+ #
22
+ # @param [String] xpath
23
+ # The XPath expression to match.
24
+ #
25
+ # @return [String, nil]
26
+ # The node text, or `nil` if no node matched.
27
+ #
28
+ def get_text(xpath)
29
+ @node.at_xpath(xpath)&.text
30
+ end
31
+
32
+ #
33
+ # Parses a Unix timestamp from raw element text.
34
+ #
35
+ # @param [String, nil] value
36
+ # The element text.
37
+ #
38
+ # @return [Time, nil]
39
+ #
40
+ # @api private
41
+ #
42
+ def parse_timestamp(value)
43
+ seconds = parse_integer(value)
44
+
45
+ Time.at(seconds) if seconds
46
+ end
47
+
48
+ #
49
+ # Parses an integer from raw element text, returning `nil` for values that
50
+ # are not plain decimal digits.
51
+ #
52
+ # @param [String, nil] value
53
+ # The element text.
54
+ #
55
+ # @return [Integer, nil]
56
+ #
57
+ # @api private
58
+ #
59
+ def parse_integer(value)
60
+ return nil if value.nil? || value.empty?
61
+
62
+ digits = value.strip
63
+ return nil unless digits.match?(/\A\d+\z/)
64
+
65
+ digits.to_i
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'dmarcator/query'
4
- require 'dmarcator/parser'
4
+ require 'dmarcator/internal/parser'
5
5
  require 'dmarcator/exceptions'
6
6
 
7
7
  require 'resolv'
@@ -66,13 +66,13 @@ module DMARCator
66
66
  @v = attributes.fetch(:v)
67
67
  @adkim = attributes[:adkim]
68
68
  @aspf = attributes[:aspf]
69
- @fo = attributes[:fo]
69
+ @fo = attributes[:fo]&.freeze
70
70
  @p = attributes[:p]
71
71
  @pct = attributes[:pct]
72
72
  @rf = attributes[:rf]
73
73
  @ri = attributes[:ri]
74
- @rua = attributes[:rua]
75
- @ruf = attributes[:ruf]
74
+ @rua = attributes[:rua]&.freeze
75
+ @ruf = attributes[:ruf]&.freeze
76
76
  @sp = attributes[:sp]
77
77
  end
78
78
 
@@ -277,7 +277,7 @@ module DMARCator
277
277
  # @api public
278
278
  #
279
279
  def self.parse(record, strict: false)
280
- attributes = Parser.parse(record, strict: strict)
280
+ attributes = Internal::Parser.parse(record, strict: strict)
281
281
 
282
282
  unless attributes[:p]
283
283
  raise InvalidRecord, "DMARC record is missing the required 'p' tag"
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../xml_helper'
3
+ require_relative '../internal/xml_helper'
4
4
 
5
5
  module DMARCator
6
6
  class Report
@@ -8,7 +8,7 @@ module DMARCator
8
8
  # Represents an `auth_results` entry (DKIM/SPF) in a DMARC XML report.
9
9
  #
10
10
  class AuthResult
11
- include XmlHelper
11
+ include Internal::XmlHelper
12
12
 
13
13
  # Attribute names exposed by this class.
14
14
  ATTRIBUTES = [:type, :domain, :result, :selector, :scope]
@@ -27,18 +27,18 @@ module DMARCator
27
27
 
28
28
  def initialize(node)
29
29
  @node = node
30
- @type = node.name
30
+ @type = node.name.freeze
31
31
 
32
32
  node.element_children.each do |element|
33
33
  case element.name
34
34
  when 'domain'
35
- @domain ||= element.text
35
+ @domain ||= element.text.freeze
36
36
  when 'result'
37
- @result ||= element.text
37
+ @result ||= element.text.freeze
38
38
  when 'selector'
39
- @selector ||= element.text
39
+ @selector ||= element.text.freeze
40
40
  when 'scope'
41
- @scope ||= element.text
41
+ @scope ||= element.text.freeze
42
42
  end
43
43
  end
44
44
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../xml_helper'
3
+ require_relative '../internal/xml_helper'
4
4
 
5
5
  module DMARCator
6
6
  class Report
@@ -8,7 +8,7 @@ module DMARCator
8
8
  # Represents the `report_metadata` element of a DMARC XML report.
9
9
  #
10
10
  class Metadata
11
- include XmlHelper
11
+ include Internal::XmlHelper
12
12
 
13
13
  # Attribute names exposed by this class.
14
14
  ATTRIBUTES = [
@@ -37,24 +37,25 @@ module DMARCator
37
37
  @errors = []
38
38
 
39
39
  metadata = node.at_xpath('feedback/report_metadata')
40
- return unless metadata
41
40
 
42
41
  metadata.element_children.each do |element|
43
42
  case element.name
44
43
  when 'org_name'
45
- @org_name ||= element.text
44
+ @org_name ||= element.text.freeze
46
45
  when 'email'
47
- @email ||= element.text
46
+ @email ||= element.text.freeze
48
47
  when 'extra_contact_info'
49
- @extra_contact_info ||= element.text
48
+ @extra_contact_info ||= element.text.freeze
50
49
  when 'report_id'
51
- @report_id ||= element.text
50
+ @report_id ||= element.text.freeze
52
51
  when 'error'
53
- errors << element.text
52
+ errors << element.text.freeze
54
53
  when 'date_range'
55
54
  parse_date_range(element)
56
55
  end
57
56
  end
57
+ ensure
58
+ @errors.freeze
58
59
  end
59
60
 
60
61
  private
@@ -63,9 +64,9 @@ module DMARCator
63
64
  date_range.element_children.each do |element|
64
65
  case element.name
65
66
  when 'begin'
66
- @begin_at ||= parse_timestamp(element.text)
67
+ @begin_at ||= parse_timestamp(element.text.freeze)
67
68
  when 'end'
68
- @end_at ||= parse_timestamp(element.text)
69
+ @end_at ||= parse_timestamp(element.text.freeze)
69
70
  end
70
71
  end
71
72
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../xml_helper'
3
+ require_relative '../internal/xml_helper'
4
4
 
5
5
  module DMARCator
6
6
  class Report
@@ -8,7 +8,7 @@ module DMARCator
8
8
  # Represents the `policy_published` element of a DMARC XML report.
9
9
  #
10
10
  class Policy
11
- include XmlHelper
11
+ include Internal::XmlHelper
12
12
 
13
13
  # Attribute names exposed by this class.
14
14
  ATTRIBUTES = [:domain, :adkim, :aspf, :p, :sp, :pct, :fo]
@@ -38,19 +38,19 @@ module DMARCator
38
38
  policy.element_children.each do |element|
39
39
  case element.name
40
40
  when 'pct'
41
- @pct ||= parse_integer(element.text)
41
+ @pct ||= parse_integer(element.text.freeze)
42
42
  when 'domain'
43
- @domain ||= element.text
43
+ @domain ||= element.text.freeze
44
44
  when 'adkim'
45
- @adkim ||= element.text
45
+ @adkim ||= element.text.freeze
46
46
  when 'aspf'
47
- @aspf ||= element.text
47
+ @aspf ||= element.text.freeze
48
48
  when 'p'
49
- @p ||= element.text
49
+ @p ||= element.text.freeze
50
50
  when 'sp'
51
- @sp ||= element.text
51
+ @sp ||= element.text.freeze
52
52
  when 'fo'
53
- @fo ||= element.text
53
+ @fo ||= element.text.freeze
54
54
  end
55
55
  end
56
56
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../xml_helper'
3
+ require_relative '../internal/xml_helper'
4
4
 
5
5
  module DMARCator
6
6
  class Report
@@ -8,7 +8,7 @@ module DMARCator
8
8
  # Represents a `reason` element of a DMARC XML report record.
9
9
  #
10
10
  class Reason
11
- include XmlHelper
11
+ include Internal::XmlHelper
12
12
 
13
13
  # Attribute names exposed by this class.
14
14
  ATTRIBUTES = [:type, :comment]
@@ -25,9 +25,9 @@ module DMARCator
25
25
  node.element_children.each do |element|
26
26
  case element.name
27
27
  when 'type'
28
- @type ||= element.text
28
+ @type ||= element.text.freeze
29
29
  when 'comment'
30
- @comment ||= element.text
30
+ @comment ||= element.text.freeze
31
31
  end
32
32
  end
33
33
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../xml_helper'
3
+ require_relative '../internal/xml_helper'
4
4
  require_relative 'auth_result'
5
5
  require_relative 'reason'
6
6
 
@@ -13,7 +13,7 @@ module DMARCator
13
13
  # DMARC TXT record.
14
14
  #
15
15
  class Row
16
- include XmlHelper
16
+ include Internal::XmlHelper
17
17
 
18
18
  # Attribute names exposed by this class.
19
19
  ATTRIBUTES = [
@@ -64,7 +64,7 @@ module DMARCator
64
64
  def reasons
65
65
  return @reasons if defined?(@reasons)
66
66
 
67
- @reasons = @reason_nodes.map { |reason_node| Reason.new(reason_node) }
67
+ @reasons = @reason_nodes.map { |reason_node| Reason.new(reason_node) }.freeze
68
68
  end
69
69
 
70
70
  #
@@ -77,7 +77,7 @@ module DMARCator
77
77
 
78
78
  @auth_results = @auth_result_nodes.map do |auth_result_node|
79
79
  AuthResult.new(auth_result_node)
80
- end
80
+ end.freeze
81
81
  end
82
82
 
83
83
  private
@@ -86,9 +86,9 @@ module DMARCator
86
86
  row.element_children.each do |element|
87
87
  case element.name
88
88
  when 'source_ip'
89
- @source_ip ||= element.text
89
+ @source_ip ||= element.text.freeze
90
90
  when 'count'
91
- @count ||= parse_integer(element.text)
91
+ @count ||= parse_integer(element.text.freeze)
92
92
  when 'policy_evaluated'
93
93
  parse_policy_evaluated(element)
94
94
  end
@@ -99,11 +99,11 @@ module DMARCator
99
99
  policy_evaluated.element_children.each do |element|
100
100
  case element.name
101
101
  when 'disposition'
102
- @disposition ||= element.text
102
+ @disposition ||= element.text.freeze
103
103
  when 'dkim'
104
- @dkim ||= element.text
104
+ @dkim ||= element.text.freeze
105
105
  when 'spf'
106
- @spf ||= element.text
106
+ @spf ||= element.text.freeze
107
107
  when 'reason'
108
108
  @reason_nodes << element
109
109
  end
@@ -114,11 +114,11 @@ module DMARCator
114
114
  identifiers.element_children.each do |element|
115
115
  case element.name
116
116
  when 'envelope_to'
117
- @envelope_to ||= element.text
117
+ @envelope_to ||= element.text.freeze
118
118
  when 'envelope_from'
119
- @envelope_from ||= element.text
119
+ @envelope_from ||= element.text.freeze
120
120
  when 'header_from'
121
- @header_from ||= element.text
121
+ @header_from ||= element.text.freeze
122
122
  end
123
123
  end
124
124
  end
@@ -3,7 +3,7 @@
3
3
  require 'nokogiri'
4
4
 
5
5
  require_relative 'config'
6
- require_relative 'xml_helper'
6
+ require_relative 'internal/xml_helper'
7
7
  require_relative 'exceptions'
8
8
  require_relative 'report/metadata'
9
9
  require_relative 'report/policy'
@@ -17,11 +17,11 @@ module DMARCator
17
17
  # resolved or fetched; treat report XML as untrusted input.
18
18
  #
19
19
  class Report
20
- include XmlHelper
20
+ include Internal::XmlHelper
21
21
 
22
22
  # Default maximum accepted report size in bytes (25 MiB).
23
23
  #
24
- # @deprecated Use {Config::DEFAULT_MAX_REPORT_BYTES}.
24
+ # @deprecated Use {Config::DEFAULT_MAX_REPORT_BYTES}. Removed in 2.0.
25
25
  #
26
26
  MAX_REPORT_BYTES = Config::DEFAULT_MAX_REPORT_BYTES
27
27
 
@@ -30,6 +30,7 @@ module DMARCator
30
30
  # The maximum accepted report size in bytes.
31
31
  #
32
32
  # @deprecated Use {DMARCator.configure} with `config.max_report_bytes`.
33
+ # Removed in 2.0.
33
34
  #
34
35
  # @return [Integer]
35
36
  #
@@ -41,6 +42,7 @@ module DMARCator
41
42
  # Sets the maximum accepted report size in bytes.
42
43
  #
43
44
  # @deprecated Use {DMARCator.configure} with `config.max_report_bytes`.
45
+ # Removed in 2.0.
44
46
  #
45
47
  # @param [Integer, nil] value
46
48
  #
@@ -100,7 +102,7 @@ module DMARCator
100
102
  value = get_text('feedback/version')
101
103
  value = value.strip if value
102
104
 
103
- value if value && value.match?(/\A\d+(?:\.\d+)*\z/)
105
+ value.freeze if value && value.match?(/\A\d+(?:\.\d+)*\z/)
104
106
  end
105
107
  end
106
108
 
@@ -130,7 +132,7 @@ module DMARCator
130
132
  def records
131
133
  @records ||= @node.xpath('feedback/record').map do |node|
132
134
  Row.new(node)
133
- end
135
+ end.freeze
134
136
  end
135
137
  end
136
138
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DMARCator
4
4
  # dmarcator version
5
- VERSION = '0.12.0'
5
+ VERSION = '1.0.0'
6
6
  end
data/lib/dmarcator.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require 'dmarcator/exceptions'
4
4
  require 'dmarcator/query'
5
5
  require 'dmarcator/record'
6
- require 'dmarcator/parser'
6
+ require 'dmarcator/internal/parser'
7
7
  require 'dmarcator/report'
8
8
  require 'dmarcator/version'
9
9
 
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.12.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman
@@ -57,7 +57,8 @@ files:
57
57
  - lib/dmarcator.rb
58
58
  - lib/dmarcator/config.rb
59
59
  - lib/dmarcator/exceptions.rb
60
- - lib/dmarcator/parser.rb
60
+ - lib/dmarcator/internal/parser.rb
61
+ - lib/dmarcator/internal/xml_helper.rb
61
62
  - lib/dmarcator/query.rb
62
63
  - lib/dmarcator/record.rb
63
64
  - lib/dmarcator/report.rb
@@ -72,7 +73,6 @@ files:
72
73
  - lib/dmarcator/resolver/encrypted.rb
73
74
  - lib/dmarcator/uri.rb
74
75
  - lib/dmarcator/version.rb
75
- - lib/dmarcator/xml_helper.rb
76
76
  homepage: https://github.com/bermannoah/dmarcator
77
77
  licenses:
78
78
  - MIT
@@ -1,348 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'dmarcator/exceptions'
4
- require 'dmarcator/uri'
5
-
6
- require 'parslet'
7
- require 'uri'
8
-
9
- module DMARCator
10
- #
11
- # DMARC parser.
12
- #
13
- # An implementation detail: use {Record.parse} rather than this class.
14
- #
15
- # @api private
16
- #
17
- # @see https://tools.ietf.org/html/rfc7489#section-6.4
18
- #
19
- class Parser < Parslet::Parser
20
-
21
- # Maximum accepted DMARC record size in bytes. Real records are far
22
- # smaller; the cap bounds parsing work on hostile input.
23
- #
24
- MAX_RECORD_BYTES = 4096
25
-
26
- root :dmarc_record
27
-
28
- rule(:dmarc_record) do
29
- dmarc_version.repeat(1,1) >>
30
- (dmarc_sep >> dmarc_tag).repeat >>
31
- dmarc_sep.maybe >>
32
- wsp?.maybe
33
- end
34
-
35
- rule(:dmarc_sep) { wsp? >> str(';') >> wsp? }
36
-
37
- rule(:dmarc_version) do
38
- str('v') >> wsp? >>
39
- str('=') >> wsp? >>
40
- str('DMARC1').as(:v)
41
- end
42
-
43
- rule(:dmarc_tag) do
44
- dmarc_request |
45
- dmarc_srequest |
46
- dmarc_auri |
47
- dmarc_furi |
48
- dmarc_adkim |
49
- dmarc_aspf |
50
- dmarc_ainterval |
51
- dmarc_fo |
52
- dmarc_rfmt |
53
- dmarc_percent |
54
- unknown_tag
55
- end
56
-
57
- #
58
- # Defines a rule for a `tag=value` DMARC tag, capturing values that do not
59
- # match the rule as `:invalid_tag` so strict mode can reject them.
60
- #
61
- # @param [Symbol] name
62
- # The rule name, used as `dmarc_<name>`.
63
- #
64
- # @param [String] tag
65
- # The DMARC tag name.
66
- #
67
- # @yieldreturn [Parslet::Atoms::Base]
68
- # The parslet matching the tag's value.
69
- #
70
- # @api private
71
- #
72
- def self.tag_rule(name,tag,&block)
73
- rule(:"dmarc_#{name}") do
74
- (
75
- str(tag) >> wsp? >> str('=') >> wsp? >>
76
- instance_eval(&block).as(tag.to_sym)
77
- ) |
78
- (
79
- str(tag) >> wsp? >> str('=') >> wsp? >>
80
- match["^; \t"].repeat(1)
81
- ).as(:invalid_tag)
82
- end
83
- end
84
-
85
- tag_rule(:request,'p') do
86
- str('none') | str('quarantine') | str('reject')
87
- end
88
-
89
- tag_rule(:srequest,'sp') do
90
- str('none') | str('quarantine') | str('reject')
91
- end
92
-
93
- tag_rule(:auri, 'rua') do
94
- dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
95
- end
96
-
97
- tag_rule(:ainterval,'ri') { digit.repeat(1, 10) >> digit.absent? }
98
-
99
- tag_rule(:furi,'ruf') do
100
- dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
101
- end
102
-
103
- tag_rule(:fo,'fo') do
104
- fo_opt >> (wsp? >> str(':') >> wsp? >> fo_opt).repeat
105
- end
106
-
107
- rule(:fo_opt) { match['01ds'].as(:fo_opt) }
108
-
109
- tag_rule(:rfmt,'rf') { str('afrf') | str('iodef') }
110
-
111
- tag_rule(:percent,'pct') { digit.repeat(1,3) >> digit.absent? }
112
-
113
- tag_rule(:adkim, 'adkim') { match['rs'] }
114
- tag_rule(:aspf, 'aspf') { match['rs'] }
115
-
116
- rule(:unknown_tag) { match["^; \t"].repeat(1).as(:unknown_tag) }
117
-
118
- rule(:dmarc_uri) do
119
- mailto_uri.as(:uri) >> (
120
- str('!') >> digit.repeat(1, 10).as(:size) >> digit.absent? >> (
121
- match['kmgt']
122
- ).as(:unit).maybe
123
- ).maybe >> str('!').absent?
124
- end
125
-
126
- rule(:uri_list_sep) { wsp? >> str(',') >> wsp? >> dmarc_uri }
127
-
128
- rule(:mailto_uri) do
129
- match['mM'] >> match['aA'] >> match['iI'] >> match['lL'] >> match['tT'] >> match['oO'] >>
130
- str(':') >> mailto_address
131
- end
132
-
133
- rule(:mailto_address) do
134
- mailto_local >> str('@') >> mailto_domain
135
- end
136
-
137
- rule(:mailto_local) { mailto_char.repeat(1) }
138
- rule(:mailto_domain) { mailto_char.repeat(1) }
139
- rule(:mailto_char) { match["A-Za-z0-9._%+-"] }
140
-
141
- rule(:digit) { match('[0-9]') }
142
- rule(:wsp) { str(' ') | str("\t") }
143
- rule(:wsp?) { wsp.repeat }
144
-
145
- #
146
- # Converts the raw parse tree into tag attributes.
147
- #
148
- # @api private
149
- #
150
- class Transform < Parslet::Transform
151
-
152
- rule(fo_opt: simple(:fo_opt)) { fo_opt }
153
- rule(fo: simple(:fo_opt)) { {fo: [fo_opt]} }
154
-
155
- rule(v: simple(:version)) { {v: version.to_sym} }
156
- rule(p: simple(:p)) { {p: p.to_sym } }
157
- rule(sp: simple(:sp)) { {sp: sp.to_sym} }
158
- rule(rf: simple(:rf)) { {rf: rf.to_sym} }
159
- rule(adkim: simple(:adkim)) { {adkim: adkim.to_sym} }
160
- rule(aspf: simple(:aspf)) { {aspf: aspf.to_sym} }
161
-
162
- rule(pct: simple(:pct)) { {pct: pct.to_i} }
163
- rule(ri: simple(:ri)) { {ri: ri.to_i} }
164
-
165
- rule(uri: simple(:uri), size: simple(:size), unit: simple(:unit)) do
166
- Uri.new(
167
- Transform.parse_uri(uri),
168
- size.to_i,
169
- unit.to_sym
170
- )
171
- end
172
- rule(uri: simple(:uri), size: simple(:size)) do
173
- Uri.new(
174
- Transform.parse_uri(uri),
175
- size.to_i
176
- )
177
- end
178
- rule(uri: simple(:uri)) { Uri.new(Transform.parse_uri(uri)) }
179
- rule(rua: subtree(:uris)) { {rua: Array(uris)} }
180
- rule(ruf: subtree(:uris)) { {ruf: Array(uris)} }
181
-
182
- # Maximum length of error messages echoed from untrusted input.
183
- MAX_ERROR_MESSAGE_LENGTH = 300
184
-
185
- #
186
- # Parses a `mailto:` URI from a parse tree slice.
187
- #
188
- # @param [Parslet::Slice] value
189
- #
190
- # @return [URI::MailTo]
191
- #
192
- # @raise [InvalidRecord]
193
- # The URI is not a valid `mailto:` URI.
194
- #
195
- # @api private
196
- #
197
- def self.parse_uri(value)
198
- uri = value.to_s
199
-
200
- begin
201
- parsed = URI.parse(uri)
202
- rescue URI::Error => error
203
- raise InvalidRecord, truncate(error.message)
204
- end
205
-
206
- unless parsed.is_a?(URI::MailTo)
207
- raise InvalidRecord, "invalid DMARC report URI (expected mailto): #{truncate(uri)}"
208
- end
209
-
210
- parsed
211
- end
212
-
213
- #
214
- # Truncates an error message to a bounded length.
215
- #
216
- # @param [String] message
217
- #
218
- # @return [String]
219
- #
220
- # @api private
221
- #
222
- def self.truncate(message)
223
- message = message.to_s
224
-
225
- if message.length > MAX_ERROR_MESSAGE_LENGTH
226
- message[0, MAX_ERROR_MESSAGE_LENGTH] + "..."
227
- else
228
- message
229
- end
230
- end
231
-
232
- end
233
-
234
- #
235
- # Parses a DMARC record.
236
- #
237
- # @param [String] record
238
- # The raw DMARC record to parse.
239
- #
240
- # @param [Boolean] strict (false)
241
- # When `true`, raise on unknown tags or invalid tag values instead of
242
- # silently ignoring them.
243
- #
244
- # @return [Hash{Symbol => Object}]
245
- # The Hash of tags within the record.
246
- #
247
- # @raise [InvalidRecord]
248
- # The record is not a String, is malformed, is too large, or (in strict
249
- # mode) contains an unknown tag or invalid tag value.
250
- #
251
- def self.parse(record, strict: false)
252
- unless record.is_a?(String)
253
- raise InvalidRecord, "DMARC record must be a String, got #{record.class}"
254
- end
255
-
256
- if record.bytesize > MAX_RECORD_BYTES
257
- raise InvalidRecord, "DMARC record exceeds maximum size of #{MAX_RECORD_BYTES} bytes"
258
- end
259
-
260
- tree = parser.parse(record)
261
-
262
- if strict
263
- tree.each do |node|
264
- next unless ignored?(node)
265
-
266
- if node.key?(:unknown_tag)
267
- raise InvalidRecord, "unknown DMARC tag: #{Transform.truncate(node[:unknown_tag].to_s).inspect}"
268
- else
269
- raise InvalidRecord, "invalid DMARC tag value: #{Transform.truncate(node[:invalid_tag].to_s).inspect}"
270
- end
271
- end
272
- end
273
-
274
- tags = transform.apply(tree.reject { |node| ignored?(node) })
275
- hash = {}
276
-
277
- tags.each do |tag|
278
- tag.each_key do |key|
279
- if hash.key?(key)
280
- raise InvalidRecord, "duplicate DMARC tag: #{key}"
281
- end
282
- end
283
-
284
- hash.merge!(tag)
285
- end
286
-
287
- validate!(hash)
288
-
289
- return hash
290
- end
291
-
292
- #
293
- # Determines whether a parse-tree node represents a tag that the lenient
294
- # parser dropped because it was unknown or had an invalid value.
295
- #
296
- # @param [Object] node
297
- # The parse-tree node.
298
- #
299
- # @return [Boolean]
300
- #
301
- def self.ignored?(node)
302
- node.is_a?(Hash) && (node.key?(:unknown_tag) || node.key?(:invalid_tag))
303
- end
304
-
305
- #
306
- # The memoized parser instance. Parslet caches rules per instance, so
307
- # reusing it avoids rebuilding the grammar on every call.
308
- #
309
- # @return [Parser]
310
- #
311
- # @api private
312
- #
313
- def self.parser
314
- @parser ||= new
315
- end
316
-
317
- #
318
- # The memoized transform instance.
319
- #
320
- # @return [Transform]
321
- #
322
- # @api private
323
- #
324
- def self.transform
325
- @transform ||= Transform.new
326
- end
327
-
328
- #
329
- # Validates the parsed tags.
330
- #
331
- # @param [Hash{Symbol => Object}] tags
332
- # The parsed tags.
333
- #
334
- # @raise [InvalidRecord]
335
- # One of the tags has an invalid value.
336
- #
337
- def self.validate!(tags)
338
- if tags[:pct] && !(tags[:pct].is_a?(Integer) && tags[:pct].between?(0, 100))
339
- raise InvalidRecord, "pct must be an integer between 0 and 100"
340
- end
341
-
342
- if tags[:ri] && !(tags[:ri].is_a?(Integer) && tags[:ri].between?(0, 2**32 - 1))
343
- raise InvalidRecord, "ri must be an integer between 0 and 4294967295"
344
- end
345
- end
346
-
347
- end
348
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'nokogiri'
4
-
5
- module DMARCator
6
- #
7
- # Internal helper for extracting values from parsed DMARC XML reports.
8
- #
9
- module XmlHelper
10
- private
11
-
12
- #
13
- # Returns the text of the first node matching the XPath expression.
14
- #
15
- # @param [String] xpath
16
- # The XPath expression to match.
17
- #
18
- # @return [String, nil]
19
- # The node text, or `nil` if no node matched.
20
- #
21
- def get_text(xpath)
22
- @node.at_xpath(xpath)&.text
23
- end
24
-
25
- #
26
- # Parses a Unix timestamp from raw element text.
27
- #
28
- # @param [String, nil] value
29
- # The element text.
30
- #
31
- # @return [Time, nil]
32
- #
33
- # @api private
34
- #
35
- def parse_timestamp(value)
36
- seconds = parse_integer(value)
37
-
38
- Time.at(seconds) if seconds
39
- end
40
-
41
- #
42
- # Parses an integer from raw element text, returning `nil` for values that
43
- # are not plain decimal digits.
44
- #
45
- # @param [String, nil] value
46
- # The element text.
47
- #
48
- # @return [Integer, nil]
49
- #
50
- # @api private
51
- #
52
- def parse_integer(value)
53
- return nil if value.nil? || value.empty?
54
-
55
- digits = value.strip
56
- return nil unless digits.match?(/\A\d+\z/)
57
-
58
- digits.to_i
59
- end
60
- end
61
- end