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 +4 -4
- data/ChangeLog.md +7 -0
- data/README.md +79 -4
- data/lib/dmarcator/query.rb +133 -22
- data/lib/dmarcator/record.rb +24 -9
- data/lib/dmarcator/resolver.rb +3 -1
- data/lib/dmarcator/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f09058293dac6f0e31294f06182d69502ee06bc152aa3eb2ef06c07515f4363b
|
|
4
|
+
data.tar.gz: 6e3aee91c8c8d098c0f066a6b301ef9bc36f42018078769f15e65d695d35ba8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
data/lib/dmarcator/query.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
111
|
-
# blocked or degraded resolver cannot silently enforce no policy.
|
|
195
|
+
# The lookup failed.
|
|
112
196
|
#
|
|
113
|
-
# @
|
|
197
|
+
# @raise [ArgumentError]
|
|
198
|
+
# The domain is not a syntactically valid DNS name.
|
|
114
199
|
#
|
|
115
|
-
# @since 0.
|
|
200
|
+
# @since 0.8.0
|
|
116
201
|
#
|
|
117
|
-
def self.
|
|
118
|
-
validate_domain(domain)
|
|
202
|
+
def self.dmarc(domain, resolver: default_resolver)
|
|
203
|
+
name = "_dmarc.#{validate_domain(domain)}"
|
|
119
204
|
|
|
120
|
-
|
|
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
|
-
|
|
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(
|
|
244
|
+
records = resolver.getresources(name, Resolv::DNS::Resource::IN::TXT)
|
|
129
245
|
rescue Resolv::ResolvError => error
|
|
130
|
-
raise ResolutionError, "DNS lookup failed for #{
|
|
246
|
+
raise ResolutionError, "DNS TXT lookup failed for #{name}: #{error.message}"
|
|
131
247
|
end
|
|
132
248
|
|
|
133
|
-
|
|
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?
|
data/lib/dmarcator/record.rb
CHANGED
|
@@ -312,31 +312,46 @@ module DMARCator
|
|
|
312
312
|
end
|
|
313
313
|
|
|
314
314
|
#
|
|
315
|
-
#
|
|
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
|
|
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
|
|
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
|
-
# @
|
|
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.
|
|
335
|
-
if (dmarc = DMARCator.
|
|
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
|
#
|
data/lib/dmarcator/resolver.rb
CHANGED
data/lib/dmarcator/version.rb
CHANGED