dmarcator 0.12.0 → 1.1.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 +19 -0
- data/README.md +7 -2
- data/lib/dmarcator/internal/parser.rb +314 -0
- data/lib/dmarcator/internal/xml_helper.rb +69 -0
- data/lib/dmarcator/record.rb +5 -33
- data/lib/dmarcator/report/auth_result.rb +7 -7
- data/lib/dmarcator/report/metadata.rb +11 -10
- data/lib/dmarcator/report/policy.rb +9 -9
- data/lib/dmarcator/report/reason.rb +4 -4
- data/lib/dmarcator/report/row.rb +12 -12
- data/lib/dmarcator/report.rb +7 -5
- data/lib/dmarcator/version.rb +1 -1
- data/lib/dmarcator.rb +1 -1
- metadata +3 -23
- data/lib/dmarcator/parser.rb +0 -348
- data/lib/dmarcator/xml_helper.rb +0 -61
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d31aa779b69d26db7ac26ef788eca2f9eca47cf5a9fc92e6522df2854e49f1f
|
|
4
|
+
data.tar.gz: 9100503cce7569587904afa95e680bd9fa0703863873a509475283c7a7f00b0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3fe6fd5ba876573069c9802643e90c79f88ff07840f60abf00addb94564a7c4bb2ce9e6180bc1bbb0960def6fb7d36686815f9938cf8b43136927b040390cfed
|
|
7
|
+
data.tar.gz: 6238af25582f02123bf5a444522cc42b65947a443dcbcfd5cf251afa1c089a99287011dcc957e07cbc124b9d8511651541dd79ac1d6e47f852c2c827a451ace0
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
### 1.1.0 / 2026-10-30
|
|
2
|
+
|
|
3
|
+
* Replace the Parslet grammar with a hand-written parser in `DMARCator::Internal`. Parsing behaviour is unchanged, verified differentially against the previous parser over 128 real-world and 47 edge-case records in both lenient and strict modes; record parsing is roughly 30x faster (0.84 ms to 0.03 ms per record).
|
|
4
|
+
* Remove the `parslet` runtime dependency. `nokogiri` is now the only runtime dependency.
|
|
5
|
+
* Messages for structurally malformed records now come from the parser itself (for example `invalid DMARC record: "..."`) and are truncated to 300 characters.
|
|
6
|
+
* `DMARCator::Internal::Parser::Transform` no longer exists. It was internal, so this is not covered by the compatibility promise.
|
|
7
|
+
* Reject records whose string encoding is invalid or not ASCII-compatible with `InvalidRecord`. Previously `Record.parse` could raise `ArgumentError` (invalid UTF-8) or `Encoding::CompatibilityError` (for example UTF-16), which escaped `rescue DMARCator::Error`. Binary strings from a DNS resolver, and other ASCII-compatible encodings such as ISO-8859-1, are unaffected.
|
|
8
|
+
|
|
9
|
+
### 1.0.0 / 2026-10-30
|
|
10
|
+
|
|
11
|
+
The public API is now frozen.
|
|
12
|
+
|
|
13
|
+
* 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.
|
|
14
|
+
* 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.
|
|
15
|
+
* 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.
|
|
16
|
+
* 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.
|
|
17
|
+
* 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.
|
|
18
|
+
* Parsing, lookup and transport behaviour are unchanged from 0.12.0; the differences above are the freeze itself.
|
|
19
|
+
|
|
1
20
|
### 0.12.0 / 2026-10-29
|
|
2
21
|
|
|
3
22
|
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
|
|
@@ -341,7 +348,6 @@ for the reporting policy and scope.
|
|
|
341
348
|
## Requirements
|
|
342
349
|
|
|
343
350
|
* [Ruby] >= 3.3.13
|
|
344
|
-
* [parslet] >= 1.0.0, < 3.0.0
|
|
345
351
|
* [nokogiri] >= 1.10
|
|
346
352
|
|
|
347
353
|
## Testing
|
|
@@ -410,6 +416,5 @@ created by D Salko and released under the MIT License.
|
|
|
410
416
|
[dmarc-parser-gem]: https://rubygems.org/gems/dmarc_parser
|
|
411
417
|
[Trail of Bits]: https://www.trailofbits.com
|
|
412
418
|
[Ruby]: https://www.ruby-lang.org
|
|
413
|
-
[parslet]: https://kschiess.github.io/parslet/
|
|
414
419
|
[nokogiri]: https://nokogiri.org
|
|
415
420
|
[LICENSE.txt]: LICENSE.txt
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'strscan'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
require_relative '../exceptions'
|
|
7
|
+
require_relative '../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
|
+
# Parses DMARC records (RFC 7489 section 6.4).
|
|
19
|
+
#
|
|
20
|
+
# A hand-written parser rather than a parser-combinator grammar: the
|
|
21
|
+
# language is small, and this keeps record parsing allocation-light and
|
|
22
|
+
# free of a runtime dependency.
|
|
23
|
+
#
|
|
24
|
+
# @see https://www.rfc-editor.org/rfc/rfc7489#section-6.4
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
#
|
|
28
|
+
class Parser
|
|
29
|
+
# Maximum accepted DMARC record size in bytes. Real records are far
|
|
30
|
+
# smaller; the cap bounds parsing work on hostile input.
|
|
31
|
+
MAX_RECORD_BYTES = 4096
|
|
32
|
+
|
|
33
|
+
# Maximum length of error messages echoed from untrusted input.
|
|
34
|
+
MAX_ERROR_MESSAGE_LENGTH = 300
|
|
35
|
+
|
|
36
|
+
# Tags whose value is one keyword from a fixed set.
|
|
37
|
+
KEYWORD_TAGS = {
|
|
38
|
+
'p' => %w[none quarantine reject],
|
|
39
|
+
'sp' => %w[none quarantine reject],
|
|
40
|
+
'adkim' => %w[r s],
|
|
41
|
+
'aspf' => %w[r s],
|
|
42
|
+
'rf' => %w[afrf iodef]
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Tags whose value is a decimal number of at most N digits.
|
|
46
|
+
DIGIT_TAGS = { 'ri' => 10, 'pct' => 3 }.freeze
|
|
47
|
+
|
|
48
|
+
# Tags whose value is a list of mailto URIs.
|
|
49
|
+
URI_TAGS = %w[rua ruf].freeze
|
|
50
|
+
|
|
51
|
+
# The `v=` tag, which may only appear first.
|
|
52
|
+
VERSION = /\Av[ \t]*=[ \t]*DMARC1\z/
|
|
53
|
+
|
|
54
|
+
# Characters allowed in a mailto local part and domain.
|
|
55
|
+
ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9._%+-]+/
|
|
56
|
+
|
|
57
|
+
# Characters allowed between tags.
|
|
58
|
+
WSP = /[ \t]*/
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
#
|
|
62
|
+
# Parses a DMARC record.
|
|
63
|
+
#
|
|
64
|
+
# @param [String] record
|
|
65
|
+
# The raw DMARC record to parse.
|
|
66
|
+
#
|
|
67
|
+
# @param [Boolean] strict (false)
|
|
68
|
+
# When `true`, raise on unknown tags or invalid tag values instead
|
|
69
|
+
# of silently ignoring them.
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash{Symbol => Object}]
|
|
72
|
+
#
|
|
73
|
+
# @raise [InvalidRecord]
|
|
74
|
+
# The record is malformed, is too large, or (in strict mode)
|
|
75
|
+
# contains an unknown tag or invalid tag value.
|
|
76
|
+
#
|
|
77
|
+
def parse(record, strict: false)
|
|
78
|
+
unless record.is_a?(String)
|
|
79
|
+
raise InvalidRecord, "DMARC record must be a String, got #{record.class}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if record.bytesize > MAX_RECORD_BYTES
|
|
83
|
+
raise InvalidRecord, "DMARC record exceeds maximum size of #{MAX_RECORD_BYTES} bytes"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Malformed or non-ASCII-compatible encodings make the regular
|
|
87
|
+
# expressions below raise ArgumentError or Encoding::CompatibilityError
|
|
88
|
+
# instead of InvalidRecord, which would escape `rescue DMARCator::Error`.
|
|
89
|
+
unless record.encoding.ascii_compatible?
|
|
90
|
+
raise InvalidRecord,
|
|
91
|
+
"DMARC record must be ASCII-compatible text, got #{record.encoding}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
unless record.valid_encoding?
|
|
95
|
+
raise InvalidRecord, "DMARC record is not valid #{record.encoding} text"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
tags = { v: :DMARC1 }
|
|
99
|
+
|
|
100
|
+
split(record).each do |segment|
|
|
101
|
+
status, key, value = parse_segment(segment)
|
|
102
|
+
|
|
103
|
+
case status
|
|
104
|
+
when :ok
|
|
105
|
+
raise InvalidRecord, "duplicate DMARC tag: #{key}" if tags.key?(key)
|
|
106
|
+
|
|
107
|
+
tags[key] = value
|
|
108
|
+
when :unknown
|
|
109
|
+
if strict
|
|
110
|
+
raise InvalidRecord, "unknown DMARC tag: #{truncate(segment).inspect}"
|
|
111
|
+
end
|
|
112
|
+
when :invalid
|
|
113
|
+
if strict
|
|
114
|
+
raise InvalidRecord, "invalid DMARC tag value: #{truncate(segment).inspect}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
validate!(tags)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
#
|
|
125
|
+
# Splits a record into tag segments, validating the version tag.
|
|
126
|
+
#
|
|
127
|
+
def split(record)
|
|
128
|
+
separator = record.index(';')
|
|
129
|
+
version = (separator ? record[0, separator] : record).sub(/[ \t]+\z/, '')
|
|
130
|
+
rest = separator ? record[(separator + 1)..] : ''
|
|
131
|
+
|
|
132
|
+
unless version.match?(VERSION)
|
|
133
|
+
raise InvalidRecord, "invalid DMARC record: #{truncate(record).inspect}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# At most one trailing separator, plus optional surrounding space.
|
|
137
|
+
rest = rest.sub(/[ \t]+\z/, '').sub(/;[ \t]*\z/, '').sub(/[ \t]+\z/, '')
|
|
138
|
+
|
|
139
|
+
return [] if rest.empty?
|
|
140
|
+
|
|
141
|
+
rest.split(';', -1).map do |segment|
|
|
142
|
+
segment = segment.sub(/\A[ \t]+/, '').sub(/[ \t]+\z/, '')
|
|
143
|
+
|
|
144
|
+
if segment.empty?
|
|
145
|
+
raise InvalidRecord, "invalid DMARC record: #{truncate(record).inspect}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
segment
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
#
|
|
153
|
+
# Classifies and parses one `tag=value` segment.
|
|
154
|
+
#
|
|
155
|
+
# @return [Array(Symbol, Symbol, Object), Array(Symbol)]
|
|
156
|
+
#
|
|
157
|
+
def parse_segment(segment)
|
|
158
|
+
name, value = segment.split('=', 2)
|
|
159
|
+
|
|
160
|
+
return [:unknown] if value.nil?
|
|
161
|
+
|
|
162
|
+
name = name.sub(/[ \t]+\z/, '').sub(/\A[ \t]+/, '')
|
|
163
|
+
value = value.sub(/\A[ \t]+/, '').sub(/[ \t]+\z/, '')
|
|
164
|
+
|
|
165
|
+
# A known tag with an empty value is not a tag at all.
|
|
166
|
+
return [:unknown] if value.empty?
|
|
167
|
+
|
|
168
|
+
if (keywords = KEYWORD_TAGS[name])
|
|
169
|
+
keyword(name, keywords, value)
|
|
170
|
+
elsif (max = DIGIT_TAGS[name])
|
|
171
|
+
digits(name, max, value)
|
|
172
|
+
elsif name == 'fo'
|
|
173
|
+
fo(value)
|
|
174
|
+
elsif URI_TAGS.include?(name)
|
|
175
|
+
uri_list(name, value, segment)
|
|
176
|
+
else
|
|
177
|
+
[:unknown]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def keyword(name, keywords, value)
|
|
182
|
+
return [:ok, name.to_sym, value.to_sym] if keywords.include?(value)
|
|
183
|
+
|
|
184
|
+
# A known keyword followed by junk is a malformed record, not an
|
|
185
|
+
# invalid value: the value grammar matched and the remainder did not.
|
|
186
|
+
malformed!(value) if keywords.any? { |keyword| value.start_with?(keyword) }
|
|
187
|
+
|
|
188
|
+
[:invalid]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def digits(name, max, value)
|
|
192
|
+
if value.match?(/\A\d+\z/)
|
|
193
|
+
return [:invalid] if value.length > max
|
|
194
|
+
|
|
195
|
+
return [:ok, name.to_sym, value.to_i]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
malformed!(value) if value.match?(/\A\d/)
|
|
199
|
+
|
|
200
|
+
[:invalid]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def fo(value)
|
|
204
|
+
return [:ok, :fo, value.split(':')] if value.match?(/\A[01ds](?::[01ds])*\z/)
|
|
205
|
+
|
|
206
|
+
malformed!(value) if value.match?(/\A[01ds](?::[01ds])*/)
|
|
207
|
+
|
|
208
|
+
[:invalid]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def uri_list(name, value, _segment)
|
|
212
|
+
uris = []
|
|
213
|
+
scanner = StringScanner.new(value)
|
|
214
|
+
|
|
215
|
+
loop do
|
|
216
|
+
uri = parse_uri(scanner)
|
|
217
|
+
|
|
218
|
+
# Any failure inside a URI makes the whole tag invalid.
|
|
219
|
+
return [:invalid] if uri.nil?
|
|
220
|
+
|
|
221
|
+
uris << uri
|
|
222
|
+
|
|
223
|
+
scanner.skip(WSP)
|
|
224
|
+
|
|
225
|
+
break if scanner.eos?
|
|
226
|
+
|
|
227
|
+
# A valid URI followed by junk is a malformed record.
|
|
228
|
+
malformed!(value) unless scanner.scan(/,/)
|
|
229
|
+
|
|
230
|
+
scanner.skip(WSP)
|
|
231
|
+
|
|
232
|
+
# A trailing comma leaves no URI to parse.
|
|
233
|
+
return [:invalid] if scanner.eos?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
[:ok, name.to_sym, uris]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
#
|
|
240
|
+
# Parses one `mailto:` URI, with its optional size and unit.
|
|
241
|
+
#
|
|
242
|
+
# @return [Uri, nil]
|
|
243
|
+
# The URI, or `nil` when the value is not a valid DMARC URI.
|
|
244
|
+
#
|
|
245
|
+
def parse_uri(scanner)
|
|
246
|
+
start = scanner.pos
|
|
247
|
+
|
|
248
|
+
return nil unless scanner.scan(/mailto:/i) && scanner.scan(ADDRESS)
|
|
249
|
+
|
|
250
|
+
uri_text = scanner.string.byteslice(start, scanner.pos - start)
|
|
251
|
+
size = unit = nil
|
|
252
|
+
|
|
253
|
+
if scanner.scan(/!/)
|
|
254
|
+
digits = scanner.scan(/\d{1,10}/)
|
|
255
|
+
|
|
256
|
+
# A bare `!` or more than ten digits is an invalid value.
|
|
257
|
+
return nil if digits.nil? || scanner.match?(/\d/)
|
|
258
|
+
|
|
259
|
+
size = digits.to_i
|
|
260
|
+
unit = scanner.scan(/[kmgt]/)&.to_sym
|
|
261
|
+
|
|
262
|
+
# A second `!` is an invalid value.
|
|
263
|
+
return nil if scanner.match?(/!/)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
parsed = begin
|
|
267
|
+
URI.parse(uri_text)
|
|
268
|
+
rescue URI::Error
|
|
269
|
+
return nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
return nil unless parsed.is_a?(URI::MailTo)
|
|
273
|
+
|
|
274
|
+
Uri.new(parsed, size, unit)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
#
|
|
278
|
+
# Raises for input whose valid prefix was followed by junk.
|
|
279
|
+
#
|
|
280
|
+
def malformed!(value)
|
|
281
|
+
raise InvalidRecord, "invalid DMARC record: #{truncate(value).inspect}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
#
|
|
285
|
+
# Validates parsed tag values that have a range.
|
|
286
|
+
#
|
|
287
|
+
def validate!(tags)
|
|
288
|
+
if tags[:pct] && !tags[:pct].between?(0, 100)
|
|
289
|
+
raise InvalidRecord, 'pct must be an integer between 0 and 100'
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
if tags[:ri] && !tags[:ri].between?(0, 2**32 - 1)
|
|
293
|
+
raise InvalidRecord, 'ri must be an integer between 0 and 4294967295'
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
tags
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
#
|
|
300
|
+
# Truncates an error message to a bounded length.
|
|
301
|
+
#
|
|
302
|
+
def truncate(message)
|
|
303
|
+
message = message.to_s
|
|
304
|
+
|
|
305
|
+
if message.length > MAX_ERROR_MESSAGE_LENGTH
|
|
306
|
+
message[0, MAX_ERROR_MESSAGE_LENGTH] + '...'
|
|
307
|
+
else
|
|
308
|
+
message
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
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
|
data/lib/dmarcator/record.rb
CHANGED
|
@@ -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,43 +277,15 @@ 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"
|
|
284
284
|
end
|
|
285
285
|
|
|
286
286
|
new(attributes)
|
|
287
|
-
rescue Parslet::ParseFailed => error
|
|
288
|
-
# Raising inside the rescue sets this error as the cause automatically.
|
|
289
|
-
raise InvalidRecord, truncate_error_message(error.message)
|
|
290
287
|
end
|
|
291
288
|
|
|
292
|
-
# Maximum length of error messages echoed from parsing untrusted input.
|
|
293
|
-
MAX_ERROR_MESSAGE_LENGTH = 500
|
|
294
|
-
|
|
295
|
-
#
|
|
296
|
-
# Truncates error messages to avoid echoing large amounts of untrusted
|
|
297
|
-
# input in exceptions.
|
|
298
|
-
#
|
|
299
|
-
# @param [String] message
|
|
300
|
-
# The error message to truncate.
|
|
301
|
-
#
|
|
302
|
-
# @return [String]
|
|
303
|
-
# The truncated error message.
|
|
304
|
-
#
|
|
305
|
-
def self.truncate_error_message(message)
|
|
306
|
-
message = message.to_s
|
|
307
|
-
|
|
308
|
-
if message.length > MAX_ERROR_MESSAGE_LENGTH
|
|
309
|
-
message[0, MAX_ERROR_MESSAGE_LENGTH] + "..."
|
|
310
|
-
else
|
|
311
|
-
message
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
private_class_method :truncate_error_message
|
|
316
|
-
|
|
317
289
|
#
|
|
318
290
|
# Looks up and parses the DMARC record for a domain.
|
|
319
291
|
#
|
|
@@ -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
|
data/lib/dmarcator/report/row.rb
CHANGED
|
@@ -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
|
data/lib/dmarcator/report.rb
CHANGED
|
@@ -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
|
data/lib/dmarcator/version.rb
CHANGED
data/lib/dmarcator.rb
CHANGED
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:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Noah Berman
|
|
@@ -9,26 +9,6 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: parslet
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: 1.0.0
|
|
19
|
-
- - "<"
|
|
20
|
-
- !ruby/object:Gem::Version
|
|
21
|
-
version: 3.0.0
|
|
22
|
-
type: :runtime
|
|
23
|
-
prerelease: false
|
|
24
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
-
requirements:
|
|
26
|
-
- - ">="
|
|
27
|
-
- !ruby/object:Gem::Version
|
|
28
|
-
version: 1.0.0
|
|
29
|
-
- - "<"
|
|
30
|
-
- !ruby/object:Gem::Version
|
|
31
|
-
version: 3.0.0
|
|
32
12
|
- !ruby/object:Gem::Dependency
|
|
33
13
|
name: nokogiri
|
|
34
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,7 +37,8 @@ files:
|
|
|
57
37
|
- lib/dmarcator.rb
|
|
58
38
|
- lib/dmarcator/config.rb
|
|
59
39
|
- lib/dmarcator/exceptions.rb
|
|
60
|
-
- lib/dmarcator/parser.rb
|
|
40
|
+
- lib/dmarcator/internal/parser.rb
|
|
41
|
+
- lib/dmarcator/internal/xml_helper.rb
|
|
61
42
|
- lib/dmarcator/query.rb
|
|
62
43
|
- lib/dmarcator/record.rb
|
|
63
44
|
- lib/dmarcator/report.rb
|
|
@@ -72,7 +53,6 @@ files:
|
|
|
72
53
|
- lib/dmarcator/resolver/encrypted.rb
|
|
73
54
|
- lib/dmarcator/uri.rb
|
|
74
55
|
- lib/dmarcator/version.rb
|
|
75
|
-
- lib/dmarcator/xml_helper.rb
|
|
76
56
|
homepage: https://github.com/bermannoah/dmarcator
|
|
77
57
|
licenses:
|
|
78
58
|
- MIT
|
data/lib/dmarcator/parser.rb
DELETED
|
@@ -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
|
data/lib/dmarcator/xml_helper.rb
DELETED
|
@@ -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
|