dmarcator 0.11.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.
@@ -1,344 +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
- # @see https://tools.ietf.org/html/rfc7489#section-6.4
14
- #
15
- class Parser < Parslet::Parser
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
-
22
- root :dmarc_record
23
-
24
- rule(:dmarc_record) do
25
- dmarc_version.repeat(1,1) >>
26
- (dmarc_sep >> dmarc_tag).repeat >>
27
- dmarc_sep.maybe >>
28
- wsp?.maybe
29
- end
30
-
31
- rule(:dmarc_sep) { wsp? >> str(';') >> wsp? }
32
-
33
- rule(:dmarc_version) do
34
- str('v') >> wsp? >>
35
- str('=') >> wsp? >>
36
- str('DMARC1').as(:v)
37
- end
38
-
39
- rule(:dmarc_tag) do
40
- dmarc_request |
41
- dmarc_srequest |
42
- dmarc_auri |
43
- dmarc_furi |
44
- dmarc_adkim |
45
- dmarc_aspf |
46
- dmarc_ainterval |
47
- dmarc_fo |
48
- dmarc_rfmt |
49
- dmarc_percent |
50
- unknown_tag
51
- end
52
-
53
- #
54
- # Defines a rule for a `tag=value` DMARC tag, capturing values that do not
55
- # match the rule as `:invalid_tag` so strict mode can reject them.
56
- #
57
- # @param [Symbol] name
58
- # The rule name, used as `dmarc_<name>`.
59
- #
60
- # @param [String] tag
61
- # The DMARC tag name.
62
- #
63
- # @yieldreturn [Parslet::Atoms::Base]
64
- # The parslet matching the tag's value.
65
- #
66
- # @api private
67
- #
68
- def self.tag_rule(name,tag,&block)
69
- rule(:"dmarc_#{name}") do
70
- (
71
- str(tag) >> wsp? >> str('=') >> wsp? >>
72
- instance_eval(&block).as(tag.to_sym)
73
- ) |
74
- (
75
- str(tag) >> wsp? >> str('=') >> wsp? >>
76
- match["^; \t"].repeat(1)
77
- ).as(:invalid_tag)
78
- end
79
- end
80
-
81
- tag_rule(:request,'p') do
82
- str('none') | str('quarantine') | str('reject')
83
- end
84
-
85
- tag_rule(:srequest,'sp') do
86
- str('none') | str('quarantine') | str('reject')
87
- end
88
-
89
- tag_rule(:auri, 'rua') do
90
- dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
91
- end
92
-
93
- tag_rule(:ainterval,'ri') { digit.repeat(1, 10) >> digit.absent? }
94
-
95
- tag_rule(:furi,'ruf') do
96
- dmarc_uri >> (uri_list_sep.present? >> uri_list_sep).repeat >> (wsp? >> str(',')).absent?
97
- end
98
-
99
- tag_rule(:fo,'fo') do
100
- fo_opt >> (wsp? >> str(':') >> wsp? >> fo_opt).repeat
101
- end
102
-
103
- rule(:fo_opt) { match['01ds'].as(:fo_opt) }
104
-
105
- tag_rule(:rfmt,'rf') { str('afrf') | str('iodef') }
106
-
107
- tag_rule(:percent,'pct') { digit.repeat(1,3) >> digit.absent? }
108
-
109
- tag_rule(:adkim, 'adkim') { match['rs'] }
110
- tag_rule(:aspf, 'aspf') { match['rs'] }
111
-
112
- rule(:unknown_tag) { match["^; \t"].repeat(1).as(:unknown_tag) }
113
-
114
- rule(:dmarc_uri) do
115
- mailto_uri.as(:uri) >> (
116
- str('!') >> digit.repeat(1, 10).as(:size) >> digit.absent? >> (
117
- match['kmgt']
118
- ).as(:unit).maybe
119
- ).maybe >> str('!').absent?
120
- end
121
-
122
- rule(:uri_list_sep) { wsp? >> str(',') >> wsp? >> dmarc_uri }
123
-
124
- rule(:mailto_uri) do
125
- match['mM'] >> match['aA'] >> match['iI'] >> match['lL'] >> match['tT'] >> match['oO'] >>
126
- str(':') >> mailto_address
127
- end
128
-
129
- rule(:mailto_address) do
130
- mailto_local >> str('@') >> mailto_domain
131
- end
132
-
133
- rule(:mailto_local) { mailto_char.repeat(1) }
134
- rule(:mailto_domain) { mailto_char.repeat(1) }
135
- rule(:mailto_char) { match["A-Za-z0-9._%+-"] }
136
-
137
- rule(:digit) { match('[0-9]') }
138
- rule(:wsp) { str(' ') | str("\t") }
139
- rule(:wsp?) { wsp.repeat }
140
-
141
- #
142
- # Converts the raw parse tree into tag attributes.
143
- #
144
- # @api private
145
- #
146
- class Transform < Parslet::Transform
147
-
148
- rule(fo_opt: simple(:fo_opt)) { fo_opt }
149
- rule(fo: simple(:fo_opt)) { {fo: [fo_opt]} }
150
-
151
- rule(v: simple(:version)) { {v: version.to_sym} }
152
- rule(p: simple(:p)) { {p: p.to_sym } }
153
- rule(sp: simple(:sp)) { {sp: sp.to_sym} }
154
- rule(rf: simple(:rf)) { {rf: rf.to_sym} }
155
- rule(adkim: simple(:adkim)) { {adkim: adkim.to_sym} }
156
- rule(aspf: simple(:aspf)) { {aspf: aspf.to_sym} }
157
-
158
- rule(pct: simple(:pct)) { {pct: pct.to_i} }
159
- rule(ri: simple(:ri)) { {ri: ri.to_i} }
160
-
161
- rule(uri: simple(:uri), size: simple(:size), unit: simple(:unit)) do
162
- Uri.new(
163
- Transform.parse_uri(uri),
164
- size.to_i,
165
- unit.to_sym
166
- )
167
- end
168
- rule(uri: simple(:uri), size: simple(:size)) do
169
- Uri.new(
170
- Transform.parse_uri(uri),
171
- size.to_i
172
- )
173
- end
174
- rule(uri: simple(:uri)) { Uri.new(Transform.parse_uri(uri)) }
175
- rule(rua: subtree(:uris)) { {rua: Array(uris)} }
176
- rule(ruf: subtree(:uris)) { {ruf: Array(uris)} }
177
-
178
- # Maximum length of error messages echoed from untrusted input.
179
- MAX_ERROR_MESSAGE_LENGTH = 300
180
-
181
- #
182
- # Parses a `mailto:` URI from a parse tree slice.
183
- #
184
- # @param [Parslet::Slice] value
185
- #
186
- # @return [URI::MailTo]
187
- #
188
- # @raise [InvalidRecord]
189
- # The URI is not a valid `mailto:` URI.
190
- #
191
- # @api private
192
- #
193
- def self.parse_uri(value)
194
- uri = value.to_s
195
-
196
- begin
197
- parsed = URI.parse(uri)
198
- rescue URI::Error => error
199
- raise InvalidRecord, truncate(error.message)
200
- end
201
-
202
- unless parsed.is_a?(URI::MailTo)
203
- raise InvalidRecord, "invalid DMARC report URI (expected mailto): #{truncate(uri)}"
204
- end
205
-
206
- parsed
207
- end
208
-
209
- #
210
- # Truncates an error message to a bounded length.
211
- #
212
- # @param [String] message
213
- #
214
- # @return [String]
215
- #
216
- # @api private
217
- #
218
- def self.truncate(message)
219
- message = message.to_s
220
-
221
- if message.length > MAX_ERROR_MESSAGE_LENGTH
222
- message[0, MAX_ERROR_MESSAGE_LENGTH] + "..."
223
- else
224
- message
225
- end
226
- end
227
-
228
- end
229
-
230
- #
231
- # Parses a DMARC record.
232
- #
233
- # @param [String] record
234
- # The raw DMARC record to parse.
235
- #
236
- # @param [Boolean] strict (false)
237
- # When `true`, raise on unknown tags or invalid tag values instead of
238
- # silently ignoring them.
239
- #
240
- # @return [Hash{Symbol => Object}]
241
- # The Hash of tags within the record.
242
- #
243
- # @raise [InvalidRecord]
244
- # The record is not a String, is malformed, is too large, or (in strict
245
- # mode) contains an unknown tag or invalid tag value.
246
- #
247
- def self.parse(record, strict: false)
248
- unless record.is_a?(String)
249
- raise InvalidRecord, "DMARC record must be a String, got #{record.class}"
250
- end
251
-
252
- if record.bytesize > MAX_RECORD_BYTES
253
- raise InvalidRecord, "DMARC record exceeds maximum size of #{MAX_RECORD_BYTES} bytes"
254
- end
255
-
256
- tree = parser.parse(record)
257
-
258
- if strict
259
- tree.each do |node|
260
- next unless ignored?(node)
261
-
262
- if node.key?(:unknown_tag)
263
- raise InvalidRecord, "unknown DMARC tag: #{Transform.truncate(node[:unknown_tag].to_s).inspect}"
264
- else
265
- raise InvalidRecord, "invalid DMARC tag value: #{Transform.truncate(node[:invalid_tag].to_s).inspect}"
266
- end
267
- end
268
- end
269
-
270
- tags = transform.apply(tree.reject { |node| ignored?(node) })
271
- hash = {}
272
-
273
- tags.each do |tag|
274
- tag.each_key do |key|
275
- if hash.key?(key)
276
- raise InvalidRecord, "duplicate DMARC tag: #{key}"
277
- end
278
- end
279
-
280
- hash.merge!(tag)
281
- end
282
-
283
- validate!(hash)
284
-
285
- return hash
286
- end
287
-
288
- #
289
- # Determines whether a parse-tree node represents a tag that the lenient
290
- # parser dropped because it was unknown or had an invalid value.
291
- #
292
- # @param [Object] node
293
- # The parse-tree node.
294
- #
295
- # @return [Boolean]
296
- #
297
- def self.ignored?(node)
298
- node.is_a?(Hash) && (node.key?(:unknown_tag) || node.key?(:invalid_tag))
299
- end
300
-
301
- #
302
- # The memoized parser instance. Parslet caches rules per instance, so
303
- # reusing it avoids rebuilding the grammar on every call.
304
- #
305
- # @return [Parser]
306
- #
307
- # @api private
308
- #
309
- def self.parser
310
- @parser ||= new
311
- end
312
-
313
- #
314
- # The memoized transform instance.
315
- #
316
- # @return [Transform]
317
- #
318
- # @api private
319
- #
320
- def self.transform
321
- @transform ||= Transform.new
322
- end
323
-
324
- #
325
- # Validates the parsed tags.
326
- #
327
- # @param [Hash{Symbol => Object}] tags
328
- # The parsed tags.
329
- #
330
- # @raise [InvalidRecord]
331
- # One of the tags has an invalid value.
332
- #
333
- def self.validate!(tags)
334
- if tags[:pct] && !(tags[:pct].is_a?(Integer) && tags[:pct].between?(0, 100))
335
- raise InvalidRecord, "pct must be an integer between 0 and 100"
336
- end
337
-
338
- if tags[:ri] && !(tags[:ri].is_a?(Integer) && tags[:ri].between?(0, 2**32 - 1))
339
- raise InvalidRecord, "ri must be an integer between 0 and 4294967295"
340
- end
341
- end
342
-
343
- end
344
- end
@@ -1,59 +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
- #
11
- # Returns the text of the first node matching the XPath expression.
12
- #
13
- # @param [String] xpath
14
- # The XPath expression to match.
15
- #
16
- # @return [String, nil]
17
- # The node text, or `nil` if no node matched.
18
- #
19
- def get_text(xpath)
20
- @node.at_xpath(xpath)&.text
21
- end
22
-
23
- #
24
- # Parses a Unix timestamp from raw element text.
25
- #
26
- # @param [String, nil] value
27
- # The element text.
28
- #
29
- # @return [Time, nil]
30
- #
31
- # @api private
32
- #
33
- def parse_timestamp(value)
34
- seconds = parse_integer(value)
35
-
36
- Time.at(seconds) if seconds
37
- end
38
-
39
- #
40
- # Parses an integer from raw element text, returning `nil` for values that
41
- # are not plain decimal digits.
42
- #
43
- # @param [String, nil] value
44
- # The element text.
45
- #
46
- # @return [Integer, nil]
47
- #
48
- # @api private
49
- #
50
- def parse_integer(value)
51
- return nil if value.nil? || value.empty?
52
-
53
- digits = value.strip
54
- return nil unless digits.match?(/\A\d+\z/)
55
-
56
- digits.to_i
57
- end
58
- end
59
- end