dmarcator 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d31aa779b69d26db7ac26ef788eca2f9eca47cf5a9fc92e6522df2854e49f1f
4
- data.tar.gz: 9100503cce7569587904afa95e680bd9fa0703863873a509475283c7a7f00b0a
3
+ metadata.gz: 48797112128c5187471c9c2ad1f907c73450cf8ef0f6910e2a22815d74221222
4
+ data.tar.gz: 89727b4555cc67beddbcb53dbbfb4845dc69e6cb8c66e37b8549fa7a4ec2ec36
5
5
  SHA512:
6
- metadata.gz: 3fe6fd5ba876573069c9802643e90c79f88ff07840f60abf00addb94564a7c4bb2ce9e6180bc1bbb0960def6fb7d36686815f9938cf8b43136927b040390cfed
7
- data.tar.gz: 6238af25582f02123bf5a444522cc42b65947a443dcbcfd5cf251afa1c089a99287011dcc957e07cbc124b9d8511651541dd79ac1d6e47f852c2c827a451ace0
6
+ metadata.gz: b6305c8a45f78711592f6a68b61236241d68a45410de08eaeeaa8e158b6337421e9b5d6c78b21ace4b0b38109fa74f3eec198ba4b5bbe6c2a49e5127ff2e69ff
7
+ data.tar.gz: 20111fdcf9bd38ff20f48a2be10ffdf4aa946850643fca2a48696fa10c041f5b3a4d49d37f6deed32f9767ed758828fce1ade967a8c12fc52cd0c1adc2ede6a2
data/ChangeLog.md CHANGED
@@ -1,3 +1,10 @@
1
+ ### 1.2.0 / 2026-10-30
2
+
3
+ * 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`.
4
+ * `DMARCATOR_DNS_TIMEOUT` now applies to plaintext lookups as well (default 5 seconds).
5
+ * `DMARCator::DNS_TIMEOUTS` is no longer used and is deprecated; it will be removed in 2.0.
6
+ * 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.
7
+
1
8
  ### 1.1.0 / 2026-10-30
2
9
 
3
10
  * 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
- directly through Ruby's `Resolv` DNS client, and parses the XML aggregate
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); `Resolv::DNS` does not lock per query,
177
- so use one resolver per thread when parallelising; and DoT/DoH open a new TLS
178
- connection per lookup, so plaintext is the better choice for bulk work.
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, and `Resolv::DNS` does not
209
- serialise concurrent lookups, so threaded applications should configure a
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, DMARC lookups use plain DNS through `Resolv`. Encrypted transports
255
- can be selected with environment variables; no DNS provider is baked in:
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: `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.
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
 
@@ -18,8 +18,9 @@ module DMARCator
18
18
  #
19
19
  # DNS query timeouts in seconds, tried in order.
20
20
  #
21
- # Applied when the library builds a plaintext resolver. Timeouts on an
22
- # injected resolver are left alone.
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,119 @@
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
+ body = socket.read(header.unpack1('n'))
111
+ raise ResolutionError, 'short DNS response' if body.nil?
112
+
113
+ body
114
+ ensure
115
+ socket&.close
116
+ end
117
+ end
118
+ end
119
+ end
@@ -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: Resolv::DNS (UDP/TCP)
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
- resolver = Resolv::DNS.new
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'
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DMARCator
4
4
  # dmarcator version
5
- VERSION = '1.1.0'
5
+ VERSION = '1.2.0'
6
6
  end
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.0
4
+ version: 1.2.0
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