dmarcator 1.1.0 → 1.2.1
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 +14 -0
- data/README.md +37 -13
- data/lib/dmarcator/query.rb +3 -2
- data/lib/dmarcator/resolver/plain.rb +124 -0
- data/lib/dmarcator/resolver.rb +3 -4
- data/lib/dmarcator/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c588b9e28df99c70cd55c29c83854064092e2ff74436c9be680afc2b2a77824c
|
|
4
|
+
data.tar.gz: 3e6692aa15ea2aa28fa451dc847c3cadbd0cb36cdaf237e8bcb6e591206f92d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 890fe93593dd8ae6cc2888910070d73606f2700ce3e6548b09cbfcca29f86067788894dbdd23d984a23a7662059514564f955ffdf476656d94110289603b8d4c
|
|
7
|
+
data.tar.gz: b3357457dbeb21bcd40dee7a412eacc9df9d60b148eae9b0f753c2ca6534d299f4ff6bd7585fcfcf67d4482eabc4f6227ac0878af1d839721de1e2cd7334853f
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
### 1.2.1 / 2026-10-30
|
|
2
|
+
|
|
3
|
+
* Add a record-parser corpus regression net: 143 records (published real-world records plus edge cases) with their accepted outcomes in lenient and strict mode, replayed by the test suite. A parser change that alters how any record parses now fails with a diff. Regenerate with `script/generate_record_corpus.rb`.
|
|
4
|
+
* Report a TCP response body shorter than its declared length as `short DNS response (expected N bytes, got M)`, instead of the opaque `malformed DNS response: limit exceeded`.
|
|
5
|
+
|
|
6
|
+
No public API changes.
|
|
7
|
+
|
|
8
|
+
### 1.2.0 / 2026-10-30
|
|
9
|
+
|
|
10
|
+
* Plaintext lookups now use DNS over TCP (`DMARCator::Resolver::Plain`) instead of `Resolv::DNS`. `Resolv` reads UDP replies into a 512-byte buffer, so a larger answer cannot be decoded, is discarded, and the resulting timeout is swallowed. Any TXT set over 512 bytes — common for SPF and for domains with several DKIM keys — was therefore returned as an empty array, which is indistinguishable from "no records published". TCP has no such limit, and failures raise `ResolutionError`.
|
|
11
|
+
* `DMARCATOR_DNS_TIMEOUT` now applies to plaintext lookups as well (default 5 seconds).
|
|
12
|
+
* `DMARCator::DNS_TIMEOUTS` is no longer used and is deprecated; it will be removed in 2.0.
|
|
13
|
+
* An injected `Resolv::DNS` still works, but carries the same 512-byte UDP limitation. Use `DMARCator::Resolver::Plain`, DoH, or DoT for SPF and DKIM accuracy.
|
|
14
|
+
|
|
1
15
|
### 1.1.0 / 2026-10-30
|
|
2
16
|
|
|
3
17
|
* 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).
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@ DMARCator is a Ruby library for parsing and querying [DMARC] records.
|
|
|
12
12
|
|
|
13
13
|
[DMARC] policies are published as DNS TXT records at `_dmarc.<domain>`. This
|
|
14
14
|
library parses those records into a structured `DMARCator::Record`, queries them
|
|
15
|
-
|
|
15
|
+
over DNS, and parses the XML aggregate
|
|
16
16
|
reports those policies generate.
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
@@ -173,9 +173,9 @@ end
|
|
|
173
173
|
```
|
|
174
174
|
|
|
175
175
|
Notes for large scans: `Record.lookup` adds a parse per domain (~0.4 ms, far
|
|
176
|
-
more than the lookup on a fast resolver);
|
|
177
|
-
so
|
|
178
|
-
|
|
176
|
+
more than the lookup on a fast resolver); each lookup opens its own TCP
|
|
177
|
+
connection, so keep one resolver per thread when parallelising; and DoT/DoH add
|
|
178
|
+
a TLS handshake per lookup, so plaintext is the better choice for bulk work.
|
|
179
179
|
|
|
180
180
|
### SPF and DKIM
|
|
181
181
|
|
|
@@ -205,9 +205,8 @@ DMARCator.configure do |config|
|
|
|
205
205
|
end
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
-
A resolver object is shared by every thread,
|
|
209
|
-
|
|
210
|
-
factory instead:
|
|
208
|
+
A resolver object is shared by every thread, so threaded applications should
|
|
209
|
+
configure a factory instead:
|
|
211
210
|
|
|
212
211
|
```ruby
|
|
213
212
|
DMARCator.configure do |config|
|
|
@@ -251,8 +250,9 @@ report.records.first.source_ip
|
|
|
251
250
|
|
|
252
251
|
## DNS transport
|
|
253
252
|
|
|
254
|
-
By default,
|
|
255
|
-
can be selected with environment variables; no DNS provider
|
|
253
|
+
By default, lookups use plain DNS over **TCP** (`DMARCator::Resolver::Plain`).
|
|
254
|
+
Encrypted transports can be selected with environment variables; no DNS provider
|
|
255
|
+
is baked in:
|
|
256
256
|
|
|
257
257
|
```sh
|
|
258
258
|
# DNS over HTTPS (RFC 8484)
|
|
@@ -271,7 +271,15 @@ export DMARCATOR_DNS_TIMEOUT=5
|
|
|
271
271
|
|
|
272
272
|
Certificates are always verified. `DMARCATOR_DOH_IP` pins the address so the
|
|
273
273
|
DoH endpoint does not have to be resolved first; TLS still verifies the URL's
|
|
274
|
-
hostname.
|
|
274
|
+
hostname. `DMARCATOR_DNS_TIMEOUT` applies to every transport, including
|
|
275
|
+
plaintext (default 5 seconds).
|
|
276
|
+
|
|
277
|
+
Plaintext uses TCP rather than UDP deliberately. `Resolv::DNS` reads UDP replies
|
|
278
|
+
into a 512-byte buffer, so a larger answer cannot be decoded, is discarded, and
|
|
279
|
+
the resulting timeout is swallowed — meaning any TXT set over 512 bytes (common
|
|
280
|
+
for SPF, and for domains with several DKIM keys) came back as an empty result,
|
|
281
|
+
indistinguishable from "no records published". TCP has no such limit, and
|
|
282
|
+
failures raise `DMARCator::ResolutionError`.
|
|
275
283
|
|
|
276
284
|
Encrypted transports fail closed: a transport failure raises
|
|
277
285
|
`DMARCator::ResolutionError` instead of being reported as a domain without a
|
|
@@ -300,9 +308,10 @@ XML helper, the internal resolver plumbing — may change in a minor release.
|
|
|
300
308
|
* `DMARCator::Record`, `DMARCator::Report` and the parsed value objects are
|
|
301
309
|
read-only after construction and safe to share between threads.
|
|
302
310
|
* The memoized parser and transform are shared, and safe for concurrent use.
|
|
303
|
-
* Resolvers are not necessarily safe for concurrent lookups:
|
|
304
|
-
|
|
305
|
-
|
|
311
|
+
* Resolvers are not necessarily safe for concurrent lookups: each lookup opens
|
|
312
|
+
its own connection, but `Resolv::DNS` (if you inject one) does not serialise
|
|
313
|
+
queries. Use one resolver per thread, or configure a factory with
|
|
314
|
+
`DMARCator.configure` so each lookup builds its own.
|
|
306
315
|
* `DMARCator.configure` mutates process-wide state. Call it during boot rather
|
|
307
316
|
than per request.
|
|
308
317
|
|
|
@@ -358,6 +367,21 @@ To run the RSpec tests:
|
|
|
358
367
|
rake spec
|
|
359
368
|
```
|
|
360
369
|
|
|
370
|
+
### Record parser corpus
|
|
371
|
+
|
|
372
|
+
`spec/support/record_corpus.txt` holds representative records — real-world
|
|
373
|
+
published records and edge cases — and `spec/support/record_corpus.json` the
|
|
374
|
+
outcomes they produced when the corpus was last accepted. The suite replays both
|
|
375
|
+
in lenient and strict mode, so a parser change that alters how any of them parses
|
|
376
|
+
fails with a diff.
|
|
377
|
+
|
|
378
|
+
If such a change is intentional, regenerate the file and review the JSON diff in
|
|
379
|
+
the commit:
|
|
380
|
+
|
|
381
|
+
```sh
|
|
382
|
+
ruby -Ilib script/generate_record_corpus.rb
|
|
383
|
+
```
|
|
384
|
+
|
|
361
385
|
## Releasing
|
|
362
386
|
|
|
363
387
|
Every published version must have an annotated tag pointing at the commit that
|
data/lib/dmarcator/query.rb
CHANGED
|
@@ -18,8 +18,9 @@ module DMARCator
|
|
|
18
18
|
#
|
|
19
19
|
# DNS query timeouts in seconds, tried in order.
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
21
|
+
# @deprecated No longer used. Plaintext lookups use a single timeout
|
|
22
|
+
# ({Resolver::Plain}), configurable with `DMARCATOR_DNS_TIMEOUT`.
|
|
23
|
+
# Removed in 2.0.
|
|
23
24
|
#
|
|
24
25
|
DNS_TIMEOUTS = [2, 4, 8].freeze
|
|
25
26
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'resolv'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
require_relative '../exceptions'
|
|
7
|
+
require_relative 'encrypted'
|
|
8
|
+
|
|
9
|
+
module DMARCator
|
|
10
|
+
module Resolver
|
|
11
|
+
#
|
|
12
|
+
# Plain DNS over TCP ([RFC 1035] section 4.2.2).
|
|
13
|
+
#
|
|
14
|
+
# TCP rather than UDP on purpose. `Resolv::DNS` reads UDP replies into a
|
|
15
|
+
# 512-byte buffer, so a larger answer cannot be decoded, is discarded, and
|
|
16
|
+
# the resulting timeout is swallowed unless `raise_timeout_errors` is set.
|
|
17
|
+
# Large TXT sets - SPF records, several DKIM keys - therefore came back as
|
|
18
|
+
# an empty result, which looks exactly like "no records published". TCP has
|
|
19
|
+
# no such limit, and failures here raise {ResolutionError}.
|
|
20
|
+
#
|
|
21
|
+
# [RFC 1035]: https://www.rfc-editor.org/rfc/rfc1035#section-4.2.2
|
|
22
|
+
#
|
|
23
|
+
# @since 1.2.0
|
|
24
|
+
#
|
|
25
|
+
class Plain < Encrypted
|
|
26
|
+
# Default DNS port.
|
|
27
|
+
DEFAULT_PORT = 53
|
|
28
|
+
|
|
29
|
+
# Default connect/read timeout in seconds.
|
|
30
|
+
DEFAULT_TIMEOUT = 5
|
|
31
|
+
|
|
32
|
+
# @return [Array<Array(String, Integer)>] the nameservers to try.
|
|
33
|
+
attr_reader :nameservers
|
|
34
|
+
|
|
35
|
+
# @return [Integer] connect/read timeout in seconds.
|
|
36
|
+
attr_reader :timeout
|
|
37
|
+
|
|
38
|
+
#
|
|
39
|
+
# Builds a resolver from environment variables.
|
|
40
|
+
#
|
|
41
|
+
# @param [Hash] env
|
|
42
|
+
#
|
|
43
|
+
# @return [Plain]
|
|
44
|
+
#
|
|
45
|
+
# @raise [ArgumentError]
|
|
46
|
+
# `DMARCATOR_DNS_TIMEOUT` is not an integer.
|
|
47
|
+
#
|
|
48
|
+
def self.from_env(env = ENV)
|
|
49
|
+
new(timeout: timeout_from(env))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.timeout_from(env)
|
|
53
|
+
value = env['DMARCATOR_DNS_TIMEOUT']
|
|
54
|
+
|
|
55
|
+
value.nil? || value.empty? ? DEFAULT_TIMEOUT : Integer(value, 10)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
#
|
|
59
|
+
# The nameservers from the system resolver configuration.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array<Array(String, Integer)>]
|
|
62
|
+
#
|
|
63
|
+
def self.system_nameservers
|
|
64
|
+
config = Resolv::DNS::Config.default_config_hash
|
|
65
|
+
|
|
66
|
+
if (ports = config[:nameserver_port]) && !ports.empty?
|
|
67
|
+
return ports.map { |host, port| [host, port || DEFAULT_PORT] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
servers = Array(config[:nameserver])
|
|
71
|
+
servers = ['127.0.0.1'] if servers.empty?
|
|
72
|
+
|
|
73
|
+
servers.map { |host| [host, DEFAULT_PORT] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize(nameservers: nil, timeout: DEFAULT_TIMEOUT)
|
|
77
|
+
@nameservers = (nameservers || self.class.system_nameservers).freeze
|
|
78
|
+
@timeout = timeout
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, 'no nameservers configured' if @nameservers.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def exchange(query)
|
|
86
|
+
wire = query.encode
|
|
87
|
+
last_error = nil
|
|
88
|
+
|
|
89
|
+
@nameservers.each do |host, port|
|
|
90
|
+
begin
|
|
91
|
+
return request(host, port, wire)
|
|
92
|
+
rescue StandardError => error
|
|
93
|
+
last_error = error
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
raise ResolutionError,
|
|
98
|
+
"DNS lookup failed: #{last_error&.message || 'no nameservers'}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request(host, port, wire)
|
|
102
|
+
socket = Socket.tcp(host, port, connect_timeout: @timeout)
|
|
103
|
+
socket.timeout = @timeout if socket.respond_to?(:timeout=)
|
|
104
|
+
|
|
105
|
+
socket.write([wire.bytesize].pack('n') + wire)
|
|
106
|
+
|
|
107
|
+
header = socket.read(2)
|
|
108
|
+
raise ResolutionError, 'short DNS response' if header.nil? || header.bytesize < 2
|
|
109
|
+
|
|
110
|
+
length = header.unpack1('n')
|
|
111
|
+
body = socket.read(length)
|
|
112
|
+
|
|
113
|
+
if body.nil? || body.bytesize != length
|
|
114
|
+
raise ResolutionError,
|
|
115
|
+
"short DNS response (expected #{length} bytes, got #{body&.bytesize || 0})"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
body
|
|
119
|
+
ensure
|
|
120
|
+
socket&.close
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/dmarcator/resolver.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'resolv'
|
|
|
4
4
|
|
|
5
5
|
require_relative 'exceptions'
|
|
6
6
|
require_relative 'resolver/encrypted'
|
|
7
|
+
require_relative 'resolver/plain'
|
|
7
8
|
require_relative 'resolver/doh'
|
|
8
9
|
require_relative 'resolver/dot'
|
|
9
10
|
|
|
@@ -14,7 +15,7 @@ module DMARCator
|
|
|
14
15
|
# The transport is chosen with the `DMARCATOR_DNS` environment variable, or
|
|
15
16
|
# explicitly via {.build}:
|
|
16
17
|
#
|
|
17
|
-
# DMARCATOR_DNS=plaintext # default:
|
|
18
|
+
# DMARCATOR_DNS=plaintext # default: TCP (Resolver::Plain)
|
|
18
19
|
# DMARCATOR_DNS=doh # requires DMARCATOR_DOH_URL
|
|
19
20
|
# DMARCATOR_DNS=dot # requires DMARCATOR_DOT_SERVER
|
|
20
21
|
#
|
|
@@ -61,9 +62,7 @@ module DMARCator
|
|
|
61
62
|
|
|
62
63
|
case name
|
|
63
64
|
when 'plaintext'
|
|
64
|
-
|
|
65
|
-
resolver.timeouts = DNS_TIMEOUTS
|
|
66
|
-
resolver
|
|
65
|
+
Plain.from_env(env)
|
|
67
66
|
when 'doh'
|
|
68
67
|
DoH.from_env(env)
|
|
69
68
|
when 'dot'
|
data/lib/dmarcator/version.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: 1.1
|
|
4
|
+
version: 1.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Noah Berman
|
|
@@ -51,6 +51,7 @@ files:
|
|
|
51
51
|
- lib/dmarcator/resolver/doh.rb
|
|
52
52
|
- lib/dmarcator/resolver/dot.rb
|
|
53
53
|
- lib/dmarcator/resolver/encrypted.rb
|
|
54
|
+
- lib/dmarcator/resolver/plain.rb
|
|
54
55
|
- lib/dmarcator/uri.rb
|
|
55
56
|
- lib/dmarcator/version.rb
|
|
56
57
|
homepage: https://github.com/bermannoah/dmarcator
|