dmarcator 0.7.0 → 0.8.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: dfd0c6f8b9f84d311a49c5ad8a6b0e43618bd151ebd9cb7532a76486f4ea600a
4
- data.tar.gz: 1b0eb0947c60844e369594d4c28e68510a91246faceb4a099f2c08dd7391a3fa
3
+ metadata.gz: f09058293dac6f0e31294f06182d69502ee06bc152aa3eb2ef06c07515f4363b
4
+ data.tar.gz: 6e3aee91c8c8d098c0f066a6b301ef9bc36f42018078769f15e65d695d35ba8a
5
5
  SHA512:
6
- metadata.gz: a151f9aabc45f12dc8107a0d4fb8c61bcb4f66880698045ba6df8dc97664f4840db0dc98af62fce3f2c77243f9250ee0df6d886908816a71b853d55f94d4ca8a
7
- data.tar.gz: 7d7d50f10d95e4d762e8a8f3f1deb9fab301f690b942e65f77129f0ee633afa108281a86947ac6fe81a59e8062fe62f51e9b492ab0f2e08940b6483ad27f8248
6
+ metadata.gz: dd47e82f78219d828832c95912a84581ad589efaec940bc25749a0a38b49692805f9afd8030e0f0bd01b2b611dd05a36fec474a8beb9866be340ba9a2e327f39
7
+ data.tar.gz: 973b12e73708b1540e912063db142ca91e07c5e29cca86e32481222e25334448b8efe754113bfeec6fae591c6411f8a82d796429c7ac36c26e4c22c8c3b714fa
data/ChangeLog.md CHANGED
@@ -1,3 +1,10 @@
1
+ ### 0.8.0 / 2026-10-29
2
+
3
+ * Add `DMARCator.txt`, which returns every TXT record at a name — unfiltered, in response order, with multi-chunk records joined. It accepts service labels such as `_dmarc` and `_domainkey` through a new relaxed `validate_txt_name`, and lets callers detect duplicate DMARC records that `dmarc` deliberately hides.
4
+ * Rename the lookup methods after what they return: `DMARCator.dmarc` (was `DMARCator.query`) and `DMARCator::Record.lookup` (was `DMARCator::Record.query`). The old names remain as deprecated aliases and emit no runtime warnings.
5
+ * Apply `DMARCATOR_DNS_TIMEOUTS` when the library builds a plaintext resolver, instead of on every lookup. An injected resolver now keeps its own configuration.
6
+ * Document the lookup APIs, bulk-scan guidance and deprecations in the README.
7
+
1
8
  ### 0.7.0 / 2026-10-29
2
9
 
3
10
  * Plaintext DNS lookups now fail closed: a lookup failure raises `DMARCator::ResolutionError` instead of being reported as a domain without a DMARC record. Previously a timeout or resolver failure looked like no policy, which failed open. This is a behavior change and applies to every transport.
data/README.md CHANGED
@@ -89,7 +89,7 @@ record.sp
89
89
  Query the DMARC record for a domain:
90
90
 
91
91
  ```ruby
92
- record = DMARCator::Record.query("twitter.com")
92
+ record = DMARCator::Record.lookup("twitter.com")
93
93
  # => #<DMARCator::Record ... @p=:reject, @rua=[...], ...>
94
94
  ```
95
95
 
@@ -98,6 +98,73 @@ Domains must be ASCII (A-labels): pass punycode such as
98
98
  `DMARCator::ResolutionError`; `nil` only means the domain genuinely has no
99
99
  DMARC record.
100
100
 
101
+ ## Lookup APIs
102
+
103
+ The lookup methods are named after what they return:
104
+
105
+ | Method | Returns | Cost |
106
+ |---|---|---|
107
+ | `DMARCator.txt(name)` | `Array<String>` — every TXT record at `name` | one lookup |
108
+ | `DMARCator.dmarc(domain)` | `String` — the DMARC record text, or `nil` | one lookup |
109
+ | `DMARCator::Record.lookup(domain)` | `Record` — parsed | one lookup + parse (~0.4 ms) |
110
+ | `DMARCator::Record.parse(text)` | `Record` — parsed from a string | parse only |
111
+
112
+ `DMARCator.txt` is the primitive: unfiltered, in response order, multi-chunk
113
+ records joined. It works for any TXT name, including `_dmarc.` and
114
+ `_domainkey.` labels, and leaves interpretation to the caller:
115
+
116
+ ```ruby
117
+ DMARCator.txt("_dmarc.example.com")
118
+ # => ["v=DMARC1; p=reject; rua=mailto:dmarc@example.com"]
119
+
120
+ DMARCator.txt("example.com")
121
+ # => ["v=spf1 include:_spf.example.com -all", "google-site-verification=abc"]
122
+
123
+ DMARCator.txt("s1._domainkey.example.com")
124
+ # => []
125
+ ```
126
+
127
+ Because it returns every record, it is also how you detect misconfiguration
128
+ that `DMARCator.dmarc` deliberately hides per RFC 7489 §6.6.3:
129
+
130
+ ```ruby
131
+ records = DMARCator.txt("_dmarc.#{domain}").select { |t| t.start_with?("v=DMARC1") }
132
+ case records.length
133
+ when 0 then :none
134
+ when 1 then :ok
135
+ else :duplicate # more than one DMARC record breaks enforcement
136
+ end
137
+ ```
138
+
139
+ All three methods accept an injected resolver, and an injected resolver keeps
140
+ its own configuration (timeouts are only applied to resolvers the library
141
+ builds):
142
+
143
+ ```ruby
144
+ resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
145
+ DMARCator.txt("_dmarc.example.com", resolver: resolver)
146
+ DMARCator.dmarc("example.com", resolver: resolver)
147
+ DMARCator::Record.lookup("example.com", resolver: resolver)
148
+ ```
149
+
150
+ ### Bulk lookups
151
+
152
+ `DMARCator.dmarc` is the cheapest DMARC path — one lookup, no parsing. For a
153
+ scan over many domains:
154
+
155
+ ```ruby
156
+ resolver = DMARCator.default_resolver # build once, not per domain
157
+ domains.each do |domain|
158
+ record = DMARCator.dmarc(domain, resolver: resolver)
159
+ # parse only when you need the fields
160
+ end
161
+ ```
162
+
163
+ Notes for large scans: `Record.lookup` adds a parse per domain (~0.4 ms, far
164
+ more than the lookup on a fast resolver); `Resolv::DNS` does not lock per query,
165
+ so use one resolver per thread when parallelising; and DoT/DoH open a new TLS
166
+ connection per lookup, so plaintext is the better choice for bulk work.
167
+
101
168
  ## Strict parsing
102
169
 
103
170
  By default, unknown tags and tags with invalid values are ignored. Pass
@@ -164,7 +231,7 @@ A resolver can also be built explicitly:
164
231
 
165
232
  ```ruby
166
233
  resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
167
- record = DMARCator::Record.query("example.com", resolver)
234
+ record = DMARCator::Record.lookup("example.com", resolver: resolver)
168
235
  ```
169
236
 
170
237
  ## Security
@@ -216,8 +283,12 @@ was built, so a gem can always be traced back to its source:
216
283
  # 2. Commit the release.
217
284
  git commit -am "Bump version to X.Y.Z"
218
285
 
219
- # 3. Tag that commit before building anything.
220
- git tag -a vX.Y.Z -m "dmarcator X.Y.Z"
286
+ # 3. Tag that commit before building anything, with release notes in the
287
+ # tag message. Repeating -m adds blank-line-separated paragraphs.
288
+ git tag -a vX.Y.Z \
289
+ -m "dmarcator X.Y.Z" \
290
+ -m "Summary of the release, including behaviour changes and upgrade notes." \
291
+ -m "See ChangeLog.md for the itemized list."
221
292
 
222
293
  # 4. Push the commit and its tags.
223
294
  git push origin main --follow-tags
@@ -227,6 +298,10 @@ gem build dmarc.gemspec
227
298
  gem push dmarcator-X.Y.Z.gem
228
299
  ```
229
300
 
301
+ The tag message is the release note: it is what `git show vX.Y.Z` prints, so
302
+ put the highlight summary and any upgrade guidance there. Write it before step
303
+ 4, because a pushed tag message cannot be changed without force-pushing the tag.
304
+
230
305
  Tag before publishing, never after: a tag without a published gem, or a gem
231
306
  without a tag, makes the two hard to reconcile later.
232
307
 
@@ -17,8 +17,18 @@ module DMARCator
17
17
  #
18
18
  # DNS query timeouts in seconds, tried in order.
19
19
  #
20
+ # Applied when the library builds a plaintext resolver. Timeouts on an
21
+ # injected resolver are left alone.
22
+ #
20
23
  DNS_TIMEOUTS = [2, 4, 8].freeze
21
24
 
25
+ #
26
+ # The maximum length of a textual DNS name, in octets.
27
+ #
28
+ # @since 0.8.0
29
+ #
30
+ MAX_TXT_NAME_LENGTH = 253
31
+
22
32
  #
23
33
  # Determines the version of the loaded `resolv` gem, if available.
24
34
  #
@@ -57,6 +67,10 @@ module DMARCator
57
67
  #
58
68
  # Validates a domain before using it in a DNS query.
59
69
  #
70
+ # Rejects underscores and reserves room for the `_dmarc.` prefix, so this is
71
+ # the validator for registrable domains rather than arbitrary DNS names.
72
+ # Use {validate_txt_name} for service labels.
73
+ #
60
74
  # @param [String] domain
61
75
  # The domain to validate.
62
76
  #
@@ -79,6 +93,37 @@ module DMARCator
79
93
  domain
80
94
  end
81
95
 
96
+ #
97
+ # Validates an arbitrary DNS name used for a TXT lookup.
98
+ #
99
+ # Unlike {validate_domain}, service labels such as `_dmarc` and `_domainkey`
100
+ # are allowed, so names may contain underscores. Length and label limits are
101
+ # still enforced so hostile input cannot reach the resolver unbounded.
102
+ #
103
+ # @param [String] name
104
+ # The DNS name to validate.
105
+ #
106
+ # @return [String]
107
+ # The validated name.
108
+ #
109
+ # @raise [ArgumentError]
110
+ # The name is not a syntactically valid DNS name.
111
+ #
112
+ # @since 0.8.0
113
+ #
114
+ def self.validate_txt_name(name)
115
+ unless name.is_a?(String) && !name.empty? &&
116
+ name.length <= MAX_TXT_NAME_LENGTH &&
117
+ !name.start_with?('.') &&
118
+ !name.include?('..') &&
119
+ name.match?(/\A[A-Za-z0-9._-]+\z/) &&
120
+ name.split('.').all? { |label| label.length <= 63 && !label.start_with?('-') && !label.end_with?('-') }
121
+ raise ArgumentError, "invalid TXT name: #{name.inspect}"
122
+ end
123
+
124
+ name
125
+ end
126
+
82
127
  #
83
128
  # Builds the resolver configured by the environment.
84
129
  #
@@ -94,50 +139,116 @@ module DMARCator
94
139
  end
95
140
 
96
141
  #
97
- # Queries a domain for the DMARC record.
142
+ # Looks up every TXT record published at a DNS name.
143
+ #
144
+ # Records are returned exactly as published: unfiltered, in response order,
145
+ # and with multi-chunk records joined. Interpreting them (SPF, DKIM, DMARC,
146
+ # domain verification) is left to the caller. Use this when the individual
147
+ # records matter, for example to detect that a domain publishes more than
148
+ # one DMARC record.
149
+ #
150
+ # @param [String] name
151
+ # The DNS name to look up, such as `example.com`, `_dmarc.example.com`, or
152
+ # `s1._domainkey.example.com`.
153
+ #
154
+ # @param [Resolv::DNS, Resolver::DoH, Resolver::DoT] resolver
155
+ # The resolver to use. When omitted, one is built from `DMARCATOR_DNS`.
156
+ # An injected resolver keeps its own configuration.
157
+ #
158
+ # @return [Array<String>]
159
+ # The TXT records, or an empty array when the name has none. NXDOMAIN and
160
+ # NODATA are indistinguishable here.
161
+ #
162
+ # @raise [ResolutionError]
163
+ # The lookup failed. A failure is never reported as an empty result, so a
164
+ # blocked or degraded resolver cannot be mistaken for an absent record.
165
+ #
166
+ # @raise [ArgumentError]
167
+ # The name is not a syntactically valid DNS name.
168
+ #
169
+ # @note Names must be ASCII: pass punycode (A-labels) rather than Unicode.
170
+ #
171
+ # @since 0.8.0
172
+ #
173
+ def self.txt(name, resolver: default_resolver)
174
+ lookup_txt(validate_txt_name(name), resolver: resolver)
175
+ end
176
+
177
+ #
178
+ # Looks up the DMARC record text published for a domain.
179
+ #
180
+ # This is the lowest-cost DMARC path: one TXT lookup and no parsing. Use it
181
+ # for bulk scans, and call {Record.parse} only on records you care about.
98
182
  #
99
183
  # @param [String] domain
100
- # The domain to query DMARC for.
184
+ # The domain to look up DMARC for.
101
185
  #
102
186
  # @param [Resolv::DNS, Resolver::DoH, Resolver::DoT] resolver
103
- # The resolver to use. When omitted, one is built from `DMARCATOR_DNS`;
104
- # plaintext resolvers receive {DNS_TIMEOUTS}.
187
+ # The resolver to use. When omitted, one is built from `DMARCATOR_DNS`.
105
188
  #
106
189
  # @return [String, nil]
107
- # The domain's DMARC record, or `nil` if the domain simply has none.
190
+ # The DMARC record text, or `nil` when the domain publishes none or
191
+ # publishes more than one (per RFC 7489 §6.6.3). Use {txt} to see every
192
+ # record, including duplicates.
108
193
  #
109
194
  # @raise [ResolutionError]
110
- # The lookup failed. A failure is never reported as a missing record, so a
111
- # blocked or degraded resolver cannot silently enforce no policy.
195
+ # The lookup failed.
112
196
  #
113
- # @api semipublic
197
+ # @raise [ArgumentError]
198
+ # The domain is not a syntactically valid DNS name.
114
199
  #
115
- # @since 0.3.0
200
+ # @since 0.8.0
116
201
  #
117
- def self.query(domain,resolver=default_resolver)
118
- validate_domain(domain)
202
+ def self.dmarc(domain, resolver: default_resolver)
203
+ name = "_dmarc.#{validate_domain(domain)}"
119
204
 
120
- resolver.timeouts = DNS_TIMEOUTS if resolver.respond_to?(:timeouts=)
205
+ # Per RFC 7489 §6.6.3, use the TXT record beginning with "v=DMARC1".
206
+ # If multiple DMARC records exist, treat the domain as having none.
207
+ dmarc_records = lookup_txt(name, resolver: resolver).select do |text|
208
+ text.start_with?('v=DMARC1')
209
+ end
210
+
211
+ dmarc_records.first if dmarc_records.one?
212
+ end
121
213
 
122
- host = "_dmarc.#{domain}"
214
+ #
215
+ # @deprecated Use {dmarc}, which names what it returns. Kept for
216
+ # compatibility; it will be removed in a future release.
217
+ #
218
+ # @see dmarc
219
+ #
220
+ def self.query(domain,resolver=default_resolver)
221
+ dmarc(domain, resolver: resolver)
222
+ end
123
223
 
224
+ #
225
+ # Looks up the TXT records at an already-validated name.
226
+ #
227
+ # Shared by {txt} and {dmarc} so both use one transport path.
228
+ #
229
+ # @param [String] name
230
+ #
231
+ # @param [Resolv::DNS, Resolver::DoH, Resolver::DoT] resolver
232
+ #
233
+ # @return [Array<String>]
234
+ #
235
+ # @raise [ResolutionError]
236
+ #
237
+ # @api private
238
+ #
239
+ def self.lookup_txt(name, resolver:)
124
240
  # Resolv returns an empty result for NXDOMAIN and raises for timeouts and
125
241
  # other failures, so any exception here is a transport failure, not an
126
242
  # absent record.
127
243
  begin
128
- records = resolver.getresources(host, Resolv::DNS::Resource::IN::TXT)
244
+ records = resolver.getresources(name, Resolv::DNS::Resource::IN::TXT)
129
245
  rescue Resolv::ResolvError => error
130
- raise ResolutionError, "DNS lookup failed for #{host}: #{error.message}"
246
+ raise ResolutionError, "DNS TXT lookup failed for #{name}: #{error.message}"
131
247
  end
132
248
 
133
- # Per RFC 7489 §6.6.3, use the TXT record beginning with "v=DMARC1".
134
- # If multiple DMARC records exist, treat the domain as having none.
135
- dmarc_records = records.map { |record| record.strings.join }
136
- .select { |text| text.start_with?('v=DMARC1') }
137
-
138
- return dmarc_records.first if dmarc_records.one?
139
- nil
249
+ records.map { |record| record.strings.join }
140
250
  end
251
+ private_class_method :lookup_txt
141
252
  end
142
253
 
143
254
  if DMARCator.vulnerable_resolv?
@@ -312,31 +312,46 @@ module DMARCator
312
312
  end
313
313
 
314
314
  #
315
- # Queries and parses the DMARC record for a domain.
315
+ # Looks up and parses the DMARC record for a domain.
316
+ #
317
+ # Prefer {DMARCator.dmarc} for bulk work: this method adds a parse
318
+ # (roughly 0.4 ms) on top of the lookup.
316
319
  #
317
320
  # @param [String] domain
318
- # The domain to query DMARC for.
321
+ # The domain to look up DMARC for.
319
322
  #
320
- # @param [Resolv::DNS] resolver
321
- # The resolver to use.
323
+ # @param [Resolv::DNS, DMARCator::Resolver::DoH, DMARCator::Resolver::DoT] resolver
324
+ # The resolver to use. When omitted, one is built from `DMARCATOR_DNS`.
322
325
  #
323
326
  # @return [Record, nil]
324
- # The parsed DMARC record. If no DMARC record was found, `nil` will be
325
- # returned.
327
+ # The parsed DMARC record, or `nil` when the domain publishes none.
326
328
  #
327
329
  # @raise [InvalidRecord]
328
330
  # The DMARC record could not be parsed.
329
331
  #
330
- # @since 0.3.0
332
+ # @raise [DMARCator::ResolutionError]
333
+ # The lookup failed.
334
+ #
335
+ # @since 0.8.0
331
336
  #
332
337
  # @api public
333
338
  #
334
- def self.query(domain,resolver=DMARCator.default_resolver)
335
- if (dmarc = DMARCator.query(domain,resolver))
339
+ def self.lookup(domain, resolver: DMARCator.default_resolver)
340
+ if (dmarc = DMARCator.dmarc(domain, resolver: resolver))
336
341
  parse(dmarc)
337
342
  end
338
343
  end
339
344
 
345
+ #
346
+ # @deprecated Use {lookup}, which names what it returns. Kept for
347
+ # compatibility; it will be removed in a future release.
348
+ #
349
+ # @see lookup
350
+ #
351
+ def self.query(domain,resolver=DMARCator.default_resolver)
352
+ lookup(domain, resolver: resolver)
353
+ end
354
+
340
355
  #
341
356
  # Converts the record to a Hash.
342
357
  #
@@ -61,7 +61,9 @@ module DMARCator
61
61
 
62
62
  case name
63
63
  when 'plaintext'
64
- Resolv::DNS.new
64
+ resolver = Resolv::DNS.new
65
+ resolver.timeouts = DNS_TIMEOUTS
66
+ resolver
65
67
  when 'doh'
66
68
  DoH.from_env(env)
67
69
  when 'dot'
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DMARCator
4
4
  # dmarcator version
5
- VERSION = '0.7.0'
5
+ VERSION = '0.8.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: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman