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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f88c01ff39e839792769a66063bd78cd312f51d5fba6917acf6777d034e0269
|
|
4
|
+
data.tar.gz: 9b39f980958757f2a27718d157ad9925424a20da358a7100d3b87f9a3765af5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c5aa75a034fedffc21ad9100de83b82f049dc08416f66cdcf2056ec6f76a8f28415ccfee012ea6432d4cbbbd760279c5a0dbce32d614f0932a0d8e9cd9efa3e
|
|
7
|
+
data.tar.gz: c1351578b215094ca74bb710725f2b66ad49f8e1c24ba14862ee32bc11099163f0d0e8cbb0736f5d23a614b6d1b801ea4fa9cd52e0a3f521f13ffffa21959897
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
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
|
+
|
|
12
|
+
### 0.12.0 / 2026-10-29
|
|
13
|
+
|
|
14
|
+
Breaking changes, taken before 1.0:
|
|
15
|
+
|
|
16
|
+
* `Report#version` returns the published value as a String instead of a `BigDecimal`, and the `bigdecimal` runtime dependency is gone. A non-numeric version still yields `nil`.
|
|
17
|
+
* Parsed report objects (`Report::Metadata`, `Policy`, `Row`, `AuthResult`, `Reason`) are read-only. Their attributes were previously writable, which allowed a parsed report to be mutated into a state that never came from the document.
|
|
18
|
+
* `Report.max_report_bytes` now delegates to `DMARCator.config.max_report_bytes`. The class-level accessors remain, so existing callers keep working; `Config#max_report_bytes` defaults to 25 MiB and rejects values that are not a positive Integer or nil.
|
|
19
|
+
|
|
20
|
+
Other changes:
|
|
21
|
+
|
|
22
|
+
* `DMARCator::XmlHelper` methods are private to including classes, `Record.truncate_error_message` is private, and `DMARCator::Parser` is documented as `@api private`.
|
|
23
|
+
* Add an API-contract spec that pins the public surface of every public class, so additions and removals are deliberate.
|
|
24
|
+
* Add `SECURITY.md` and document the stability, thread-safety and deprecation policies in the README.
|
|
25
|
+
|
|
26
|
+
Migration:
|
|
27
|
+
|
|
28
|
+
| Before | After |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `report.version == BigDecimal("0.1")` | `report.version == "0.1"` |
|
|
31
|
+
| `report.policy.pct = 50` | assign nothing; parsed values are read-only |
|
|
32
|
+
| `Report.max_report_bytes = n` | `DMARCator.configure { |c| c.max_report_bytes = n }` |
|
|
33
|
+
|
|
1
34
|
### 0.11.0 / 2026-10-29
|
|
2
35
|
|
|
3
36
|
* Add `DMARCator.configure` for process-wide defaults, so a resolver can be set from code instead of only through the `DMARCATOR_DNS` environment variables. A configured resolver takes precedence over the environment, and `DMARCator.reset_configuration!` discards it.
|
data/README.md
CHANGED
|
@@ -201,6 +201,7 @@ Process-wide defaults can be set from code, and take precedence over the
|
|
|
201
201
|
```ruby
|
|
202
202
|
DMARCator.configure do |config|
|
|
203
203
|
config.resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
|
|
204
|
+
config.max_report_bytes = 5 * 1024 * 1024
|
|
204
205
|
end
|
|
205
206
|
```
|
|
206
207
|
|
|
@@ -236,7 +237,7 @@ Parse a DMARC aggregate/forensic report:
|
|
|
236
237
|
report = DMARCator::Report.new(xml_string)
|
|
237
238
|
|
|
238
239
|
report.version
|
|
239
|
-
# => 0.
|
|
240
|
+
# => "0.1"
|
|
240
241
|
|
|
241
242
|
report.metadata.org_name
|
|
242
243
|
# => "google.com"
|
|
@@ -287,18 +288,51 @@ resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
|
|
|
287
288
|
record = DMARCator::Record.lookup("example.com", resolver: resolver)
|
|
288
289
|
```
|
|
289
290
|
|
|
291
|
+
## Stability
|
|
292
|
+
|
|
293
|
+
The public API is pinned by a contract spec in the test suite: the module
|
|
294
|
+
methods, constants and per-class public methods it asserts are the supported
|
|
295
|
+
surface. Anything marked `@api private` in the documentation — the parser, the
|
|
296
|
+
XML helper, the internal resolver plumbing — may change in a minor release.
|
|
297
|
+
|
|
298
|
+
### Thread safety
|
|
299
|
+
|
|
300
|
+
* `DMARCator::Record`, `DMARCator::Report` and the parsed value objects are
|
|
301
|
+
read-only after construction and safe to share between threads.
|
|
302
|
+
* The memoized parser and transform are shared, and safe for concurrent use.
|
|
303
|
+
* Resolvers are not necessarily safe for concurrent lookups: `Resolv::DNS` does
|
|
304
|
+
not serialise queries. Use one resolver per thread, or configure a factory
|
|
305
|
+
with `DMARCator.configure` so each lookup builds its own.
|
|
306
|
+
* `DMARCator.configure` mutates process-wide state. Call it during boot rather
|
|
307
|
+
than per request.
|
|
308
|
+
|
|
309
|
+
### Deprecations
|
|
310
|
+
|
|
311
|
+
A method is deprecated in a minor release by documentation only — no runtime
|
|
312
|
+
warnings, so nothing is added to application logs. Deprecated methods are
|
|
313
|
+
removed in the next major release.
|
|
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
|
+
|
|
290
322
|
## Security
|
|
291
323
|
|
|
292
|
-
DMARC records and XML reports are treated as untrusted input
|
|
324
|
+
DMARC records and XML reports are treated as untrusted input. See
|
|
325
|
+
[SECURITY.md](https://github.com/bermannoah/dmarcator/blob/main/SECURITY.md)
|
|
326
|
+
for the reporting policy and scope.
|
|
293
327
|
|
|
294
328
|
* XML reports are parsed with network access disabled (`NONET`), so external
|
|
295
329
|
entities are never resolved or fetched.
|
|
296
|
-
* Reports larger than `DMARCator
|
|
330
|
+
* Reports larger than `DMARCator.config.max_report_bytes` (25 MiB by
|
|
297
331
|
default) are rejected before parsing. Parsing builds a DOM roughly 13× the
|
|
298
332
|
input size, so lower the limit to bound memory:
|
|
299
333
|
|
|
300
334
|
```ruby
|
|
301
|
-
DMARCator
|
|
335
|
+
DMARCator.configure { |config| config.max_report_bytes = 5 * 1024 * 1024 }
|
|
302
336
|
```
|
|
303
337
|
* A warning is emitted at load time if the bundled `resolv` gem is affected by
|
|
304
338
|
CVE-2026-80212/CVE-2026-80213.
|
|
@@ -316,7 +350,6 @@ DMARC records and XML reports are treated as untrusted input:
|
|
|
316
350
|
* [Ruby] >= 3.3.13
|
|
317
351
|
* [parslet] >= 1.0.0, < 3.0.0
|
|
318
352
|
* [nokogiri] >= 1.10
|
|
319
|
-
* [bigdecimal] >= 1.0
|
|
320
353
|
|
|
321
354
|
## Testing
|
|
322
355
|
|
|
@@ -386,5 +419,4 @@ created by D Salko and released under the MIT License.
|
|
|
386
419
|
[Ruby]: https://www.ruby-lang.org
|
|
387
420
|
[parslet]: https://kschiess.github.io/parslet/
|
|
388
421
|
[nokogiri]: https://nokogiri.org
|
|
389
|
-
[bigdecimal]: https://github.com/ruby/bigdecimal
|
|
390
422
|
[LICENSE.txt]: LICENSE.txt
|
data/lib/dmarcator/config.rb
CHANGED
|
@@ -7,6 +7,15 @@ module DMARCator
|
|
|
7
7
|
# @since 0.11.0
|
|
8
8
|
#
|
|
9
9
|
class Config
|
|
10
|
+
# Default maximum accepted report size in bytes (25 MiB).
|
|
11
|
+
#
|
|
12
|
+
# Parsing a report builds a DOM roughly 13x the input size, so this bounds
|
|
13
|
+
# the memory a hostile report can cost.
|
|
14
|
+
#
|
|
15
|
+
# @since 0.12.0
|
|
16
|
+
#
|
|
17
|
+
DEFAULT_MAX_REPORT_BYTES = 25 * 1024 * 1024
|
|
18
|
+
|
|
10
19
|
#
|
|
11
20
|
# The default resolver used when a lookup is not given one.
|
|
12
21
|
#
|
|
@@ -18,6 +27,36 @@ module DMARCator
|
|
|
18
27
|
# @return [Object, #call, nil]
|
|
19
28
|
#
|
|
20
29
|
attr_accessor :resolver
|
|
30
|
+
|
|
31
|
+
#
|
|
32
|
+
# The maximum accepted report size in bytes.
|
|
33
|
+
#
|
|
34
|
+
# Defaults to {DEFAULT_MAX_REPORT_BYTES}. Lower it to bound memory for
|
|
35
|
+
# reports from untrusted sources.
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer]
|
|
38
|
+
#
|
|
39
|
+
def max_report_bytes
|
|
40
|
+
@max_report_bytes || DEFAULT_MAX_REPORT_BYTES
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#
|
|
44
|
+
# Sets the maximum accepted report size in bytes.
|
|
45
|
+
#
|
|
46
|
+
# @param [Integer, nil] value
|
|
47
|
+
# A positive number of bytes, or `nil` to restore the default.
|
|
48
|
+
#
|
|
49
|
+
# @raise [ArgumentError]
|
|
50
|
+
# The value is not a positive Integer or nil.
|
|
51
|
+
#
|
|
52
|
+
def max_report_bytes=(value)
|
|
53
|
+
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
"max_report_bytes must be a positive Integer or nil, got #{value.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@max_report_bytes = value
|
|
59
|
+
end
|
|
21
60
|
end
|
|
22
61
|
|
|
23
62
|
#
|
|
@@ -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
|
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,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"
|
|
@@ -312,6 +312,8 @@ module DMARCator
|
|
|
312
312
|
end
|
|
313
313
|
end
|
|
314
314
|
|
|
315
|
+
private_class_method :truncate_error_message
|
|
316
|
+
|
|
315
317
|
#
|
|
316
318
|
# Looks up and parses the DMARC record for a domain.
|
|
317
319
|
#
|