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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d31aa779b69d26db7ac26ef788eca2f9eca47cf5a9fc92e6522df2854e49f1f
4
- data.tar.gz: 9100503cce7569587904afa95e680bd9fa0703863873a509475283c7a7f00b0a
3
+ metadata.gz: c588b9e28df99c70cd55c29c83854064092e2ff74436c9be680afc2b2a77824c
4
+ data.tar.gz: 3e6692aa15ea2aa28fa451dc847c3cadbd0cb36cdaf237e8bcb6e591206f92d0
5
5
  SHA512:
6
- metadata.gz: 3fe6fd5ba876573069c9802643e90c79f88ff07840f60abf00addb94564a7c4bb2ce9e6180bc1bbb0960def6fb7d36686815f9938cf8b43136927b040390cfed
7
- data.tar.gz: 6238af25582f02123bf5a444522cc42b65947a443dcbcfd5cf251afa1c089a99287011dcc957e07cbc124b9d8511651541dd79ac1d6e47f852c2c827a451ace0
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
- 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
 
@@ -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
@@ -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,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
@@ -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.1'
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.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