dmarcator 0.7.0 → 0.9.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: f4c6e25ca7a0fc2d77204c72ebb01906fce1ac4b537b1e011e505ed2938dda2b
4
+ data.tar.gz: 36f390d4c0e42c5e938df0345e9d412de2b09aed29da9ca83a424414090b88f1
5
5
  SHA512:
6
- metadata.gz: a151f9aabc45f12dc8107a0d4fb8c61bcb4f66880698045ba6df8dc97664f4840db0dc98af62fce3f2c77243f9250ee0df6d886908816a71b853d55f94d4ca8a
7
- data.tar.gz: 7d7d50f10d95e4d762e8a8f3f1deb9fab301f690b942e65f77129f0ee633afa108281a86947ac6fe81a59e8062fe62f51e9b492ab0f2e08940b6483ad27f8248
6
+ metadata.gz: 8f3db5da3231a84613e3a938d3e294f01da16df7aee2e633ba321e0002a8278ec28755accb08a1db2e6c72f2d0424cb34ea019b3168b32872503f59dddb6eaa4
7
+ data.tar.gz: 28d72632daef6199571fd1cd461c112610ebd14bd45a03b48c6b9127f571e82f8431ad35331e4c05d52482b2b5d085bb973a51aa37a888ede498b8b7a85f4eac
data/ChangeLog.md CHANGED
@@ -1,3 +1,14 @@
1
+ ### 0.9.0 / 2026-10-29
2
+
3
+ * Add `DMARCator.txt_records`, which returns the TXT resource records instead of strings, so callers that cache lookups can read each record's `ttl`. `DMARCator.txt` is now defined in terms of it and behaves the same.
4
+
5
+ ### 0.8.0 / 2026-10-29
6
+
7
+ * 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.
8
+ * 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.
9
+ * Apply `DMARCATOR_DNS_TIMEOUTS` when the library builds a plaintext resolver, instead of on every lookup. An injected resolver now keeps its own configuration.
10
+ * Document the lookup APIs, bulk-scan guidance and deprecations in the README.
11
+
1
12
  ### 0.7.0 / 2026-10-29
2
13
 
3
14
  * 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,85 @@ 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_records(name)` | `Array<Resolv::DNS::Resource::IN::TXT>` — the records themselves | one lookup |
108
+ | `DMARCator.txt(name)` | `Array<String>` — every TXT record at `name` | one lookup |
109
+ | `DMARCator.dmarc(domain)` | `String` — the DMARC record text, or `nil` | one lookup |
110
+ | `DMARCator::Record.lookup(domain)` | `Record` — parsed | one lookup + parse (~0.4 ms) |
111
+ | `DMARCator::Record.parse(text)` | `Record` — parsed from a string | parse only |
112
+
113
+ `DMARCator.txt` is the primitive: unfiltered, in response order, multi-chunk
114
+ records joined. It works for any TXT name, including `_dmarc.` and
115
+ `_domainkey.` labels, and leaves interpretation to the caller:
116
+
117
+ ```ruby
118
+ DMARCator.txt("_dmarc.example.com")
119
+ # => ["v=DMARC1; p=reject; rua=mailto:dmarc@example.com"]
120
+
121
+ DMARCator.txt("example.com")
122
+ # => ["v=spf1 include:_spf.example.com -all", "google-site-verification=abc"]
123
+
124
+ DMARCator.txt("s1._domainkey.example.com")
125
+ # => []
126
+ ```
127
+
128
+ Because it returns every record, it is also how you detect misconfiguration
129
+ that `DMARCator.dmarc` deliberately hides per RFC 7489 §6.6.3:
130
+
131
+ ```ruby
132
+ records = DMARCator.txt("_dmarc.#{domain}").select { |t| t.start_with?("v=DMARC1") }
133
+ case records.length
134
+ when 0 then :none
135
+ when 1 then :ok
136
+ else :duplicate # more than one DMARC record breaks enforcement
137
+ end
138
+ ```
139
+
140
+ Use `DMARCator.txt_records` when you need the DNS TTL, for example to cache
141
+ lookups in a database:
142
+
143
+ ```ruby
144
+ records = DMARCator.txt_records("_dmarc.#{domain}")
145
+ records.each do |record|
146
+ record.strings.join # the TXT value
147
+ record.ttl # seconds the answer may be cached
148
+ end
149
+ ```
150
+
151
+ All of these accept an injected resolver, and an injected resolver keeps its
152
+ own configuration (timeouts are only applied to resolvers the library builds):
153
+
154
+ ```ruby
155
+ resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
156
+ DMARCator.txt("_dmarc.example.com", resolver: resolver)
157
+ DMARCator.txt_records("_dmarc.example.com", resolver: resolver)
158
+ DMARCator.dmarc("example.com", resolver: resolver)
159
+ DMARCator::Record.lookup("example.com", resolver: resolver)
160
+ ```
161
+
162
+ ### Bulk lookups
163
+
164
+ `DMARCator.dmarc` is the cheapest DMARC path — one lookup, no parsing. For a
165
+ scan over many domains:
166
+
167
+ ```ruby
168
+ resolver = DMARCator.default_resolver # build once, not per domain
169
+ domains.each do |domain|
170
+ record = DMARCator.dmarc(domain, resolver: resolver)
171
+ # parse only when you need the fields
172
+ end
173
+ ```
174
+
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.
179
+
101
180
  ## Strict parsing
102
181
 
103
182
  By default, unknown tags and tags with invalid values are ignored. Pass
@@ -164,7 +243,7 @@ A resolver can also be built explicitly:
164
243
 
165
244
  ```ruby
166
245
  resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
167
- record = DMARCator::Record.query("example.com", resolver)
246
+ record = DMARCator::Record.lookup("example.com", resolver: resolver)
168
247
  ```
169
248
 
170
249
  ## Security
@@ -216,8 +295,12 @@ was built, so a gem can always be traced back to its source:
216
295
  # 2. Commit the release.
217
296
  git commit -am "Bump version to X.Y.Z"
218
297
 
219
- # 3. Tag that commit before building anything.
220
- git tag -a vX.Y.Z -m "dmarcator X.Y.Z"
298
+ # 3. Tag that commit before building anything, with release notes in the
299
+ # tag message. Repeating -m adds blank-line-separated paragraphs.
300
+ git tag -a vX.Y.Z \
301
+ -m "dmarcator X.Y.Z" \
302
+ -m "Summary of the release, including behaviour changes and upgrade notes." \
303
+ -m "See ChangeLog.md for the itemized list."
221
304
 
222
305
  # 4. Push the commit and its tags.
223
306
  git push origin main --follow-tags
@@ -227,6 +310,10 @@ gem build dmarc.gemspec
227
310
  gem push dmarcator-X.Y.Z.gem
228
311
  ```
229
312
 
313
+ The tag message is the release note: it is what `git show vX.Y.Z` prints, so
314
+ put the highlight summary and any upgrade guidance there. Write it before step
315
+ 4, because a pushed tag message cannot be changed without force-pushing the tag.
316
+
230
317
  Tag before publishing, never after: a tag without a published gem, or a gem
231
318
  without a tag, makes the two hard to reconcile later.
232
319
 
@@ -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,151 @@ 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
+ txt_records(name, resolver: resolver).map { |record| record.strings.join }
175
+ end
176
+
177
+ #
178
+ # Looks up every TXT record at a DNS name, returning the resource records
179
+ # themselves.
180
+ #
181
+ # Like {txt}, but without flattening to strings. The records expose
182
+ # `strings` and `ttl`, which matters when caching results: use
183
+ # `record.ttl` to decide how long an answer may be reused.
184
+ #
185
+ # @param [String] name
186
+ # The DNS name to look up.
187
+ #
188
+ # @param [Resolv::DNS, Resolver::DoH, Resolver::DoT] resolver
189
+ # The resolver to use. When omitted, one is built from `DMARCATOR_DNS`.
190
+ # An injected resolver keeps its own configuration.
191
+ #
192
+ # @return [Array<Resolv::DNS::Resource::IN::TXT>]
193
+ # The records, or an empty array when the name has none. NXDOMAIN and
194
+ # NODATA are indistinguishable here.
195
+ #
196
+ # @raise [ResolutionError]
197
+ # The lookup failed. A failure is never reported as an empty result.
198
+ #
199
+ # @raise [ArgumentError]
200
+ # The name is not a syntactically valid DNS name.
201
+ #
202
+ # @note Names must be ASCII: pass punycode (A-labels) rather than Unicode.
203
+ #
204
+ # @since 0.9.0
205
+ #
206
+ def self.txt_records(name, resolver: default_resolver)
207
+ lookup_txt(validate_txt_name(name), resolver: resolver)
208
+ end
209
+
210
+ #
211
+ # Looks up the DMARC record text published for a domain.
212
+ #
213
+ # This is the lowest-cost DMARC path: one TXT lookup and no parsing. Use it
214
+ # for bulk scans, and call {Record.parse} only on records you care about.
98
215
  #
99
216
  # @param [String] domain
100
- # The domain to query DMARC for.
217
+ # The domain to look up DMARC for.
101
218
  #
102
219
  # @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}.
220
+ # The resolver to use. When omitted, one is built from `DMARCATOR_DNS`.
105
221
  #
106
222
  # @return [String, nil]
107
- # The domain's DMARC record, or `nil` if the domain simply has none.
223
+ # The DMARC record text, or `nil` when the domain publishes none or
224
+ # publishes more than one (per RFC 7489 §6.6.3). Use {txt} to see every
225
+ # record, including duplicates.
108
226
  #
109
227
  # @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.
228
+ # The lookup failed.
112
229
  #
113
- # @api semipublic
230
+ # @raise [ArgumentError]
231
+ # The domain is not a syntactically valid DNS name.
114
232
  #
115
- # @since 0.3.0
233
+ # @since 0.8.0
116
234
  #
117
- def self.query(domain,resolver=default_resolver)
118
- validate_domain(domain)
235
+ def self.dmarc(domain, resolver: default_resolver)
236
+ name = "_dmarc.#{validate_domain(domain)}"
237
+
238
+ # Per RFC 7489 §6.6.3, use the TXT record beginning with "v=DMARC1".
239
+ # If multiple DMARC records exist, treat the domain as having none.
240
+ dmarc_records = lookup_txt(name, resolver: resolver).map do |record|
241
+ record.strings.join
242
+ end.select do |text|
243
+ text.start_with?('v=DMARC1')
244
+ end
119
245
 
120
- resolver.timeouts = DNS_TIMEOUTS if resolver.respond_to?(:timeouts=)
246
+ dmarc_records.first if dmarc_records.one?
247
+ end
121
248
 
122
- host = "_dmarc.#{domain}"
249
+ #
250
+ # @deprecated Use {dmarc}, which names what it returns. Kept for
251
+ # compatibility; it will be removed in a future release.
252
+ #
253
+ # @see dmarc
254
+ #
255
+ def self.query(domain,resolver=default_resolver)
256
+ dmarc(domain, resolver: resolver)
257
+ end
123
258
 
259
+ #
260
+ # Looks up the TXT records at an already-validated name.
261
+ #
262
+ # Shared by {txt}, {txt_records} and {dmarc} so they use one transport path.
263
+ #
264
+ # @param [String] name
265
+ #
266
+ # @param [Resolv::DNS, Resolver::DoH, Resolver::DoT] resolver
267
+ #
268
+ # @return [Array<Resolv::DNS::Resource::IN::TXT>]
269
+ #
270
+ # @raise [ResolutionError]
271
+ #
272
+ # @api private
273
+ #
274
+ def self.lookup_txt(name, resolver:)
124
275
  # Resolv returns an empty result for NXDOMAIN and raises for timeouts and
125
276
  # other failures, so any exception here is a transport failure, not an
126
277
  # absent record.
127
278
  begin
128
- records = resolver.getresources(host, Resolv::DNS::Resource::IN::TXT)
279
+ records = resolver.getresources(name, Resolv::DNS::Resource::IN::TXT)
129
280
  rescue Resolv::ResolvError => error
130
- raise ResolutionError, "DNS lookup failed for #{host}: #{error.message}"
281
+ raise ResolutionError, "DNS TXT lookup failed for #{name}: #{error.message}"
131
282
  end
132
283
 
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
284
+ records
140
285
  end
286
+ private_class_method :lookup_txt
141
287
  end
142
288
 
143
289
  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.9.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.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman