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.
- checksums.yaml +4 -4
- data/ChangeLog.md +33 -0
- data/README.md +38 -6
- data/lib/dmarcator/config.rb +39 -0
- data/lib/dmarcator/internal/parser.rb +356 -0
- data/lib/dmarcator/internal/xml_helper.rb +69 -0
- data/lib/dmarcator/record.rb +7 -5
- data/lib/dmarcator/report/auth_result.rb +13 -13
- data/lib/dmarcator/report/metadata.rb +20 -19
- data/lib/dmarcator/report/policy.rb +17 -17
- data/lib/dmarcator/report/reason.rb +7 -7
- data/lib/dmarcator/report/row.rb +21 -22
- data/lib/dmarcator/report.rb +37 -24
- data/lib/dmarcator/version.rb +1 -1
- data/lib/dmarcator.rb +1 -1
- metadata +3 -17
- data/lib/dmarcator/parser.rb +0 -344
- data/lib/dmarcator/xml_helper.rb +0 -59
data/lib/dmarcator/parser.rb
DELETED
|
@@ -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
|
data/lib/dmarcator/xml_helper.rb
DELETED
|
@@ -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
|