dmarcator 0.8.0 → 0.10.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 +22 -0
- data/README.md +31 -3
- data/lib/dmarcator/exceptions.rb +17 -6
- data/lib/dmarcator/query.rb +39 -14
- data/lib/dmarcator/record.rb +2 -11
- data/lib/dmarcator/report/{record.rb → row.rb} +4 -1
- data/lib/dmarcator/report.rb +3 -3
- data/lib/dmarcator/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1bd93bc4b534e2b9613241e920dd4a71c41e564c92c10ab8abaf6be7294feb8
|
|
4
|
+
data.tar.gz: fb95401f394f53189a0db431f7d31bc4ffe8c25c87ce87f825f18a99962dadba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c94318c4ac8a5f7089276f5d6bf91ae90ab8cf576f6367acf418e86adba5fe4bb5e28f7df4a478f8783d134b82bd9390555b3af48c713ca869fd276be4cfe08b
|
|
7
|
+
data.tar.gz: 6b10b30dfc64acd1cdef7427c4a475d7d9fa90b4c9d387cbc1e2293e8b693e66d3fa4bc23d80d254d92a5a4137ab65b1fe98adfc0e28e573bafc8342274e7995
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
### 0.10.0 / 2026-10-29
|
|
2
|
+
|
|
3
|
+
Breaking changes, taken before 1.0 so the release does not carry them:
|
|
4
|
+
|
|
5
|
+
* Add `DMARCator::Error` as the base class for every error the library raises. `InvalidRecord`, `InvalidReport` and `ResolutionError` inherit from it, so one `rescue DMARCator::Error` catches all three.
|
|
6
|
+
* `InvalidRecord` no longer inherits from `Parslet::ParseFailed`, removing the parser implementation from the public exception contract. Parse failures still expose the underlying parser error as their `cause`.
|
|
7
|
+
* Remove the deprecated `DMARCator.query` and `DMARCator::Record.query` aliases. Use `DMARCator.dmarc` and `DMARCator::Record.lookup`.
|
|
8
|
+
* Rename `DMARCator::Report::Record` to `DMARCator::Report::Row`, so aggregate rows are not confused with `DMARCator::Record`, the parsed DMARC TXT record.
|
|
9
|
+
|
|
10
|
+
Migration:
|
|
11
|
+
|
|
12
|
+
| Before | After |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `DMARCator.query(domain)` | `DMARCator.dmarc(domain)` |
|
|
15
|
+
| `DMARCator::Record.query(domain)` | `DMARCator::Record.lookup(domain)` |
|
|
16
|
+
| `DMARCator::Report::Record` | `DMARCator::Report::Row` |
|
|
17
|
+
| `rescue Parslet::ParseFailed` | `rescue DMARCator::Error` |
|
|
18
|
+
|
|
19
|
+
### 0.9.0 / 2026-10-29
|
|
20
|
+
|
|
21
|
+
* 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.
|
|
22
|
+
|
|
1
23
|
### 0.8.0 / 2026-10-29
|
|
2
24
|
|
|
3
25
|
* 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.
|
data/README.md
CHANGED
|
@@ -104,6 +104,7 @@ The lookup methods are named after what they return:
|
|
|
104
104
|
|
|
105
105
|
| Method | Returns | Cost |
|
|
106
106
|
|---|---|---|
|
|
107
|
+
| `DMARCator.txt_records(name)` | `Array<Resolv::DNS::Resource::IN::TXT>` — the records themselves | one lookup |
|
|
107
108
|
| `DMARCator.txt(name)` | `Array<String>` — every TXT record at `name` | one lookup |
|
|
108
109
|
| `DMARCator.dmarc(domain)` | `String` — the DMARC record text, or `nil` | one lookup |
|
|
109
110
|
| `DMARCator::Record.lookup(domain)` | `Record` — parsed | one lookup + parse (~0.4 ms) |
|
|
@@ -136,13 +137,24 @@ else :duplicate # more than one DMARC record breaks enforcement
|
|
|
136
137
|
end
|
|
137
138
|
```
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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):
|
|
142
153
|
|
|
143
154
|
```ruby
|
|
144
155
|
resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
|
|
145
156
|
DMARCator.txt("_dmarc.example.com", resolver: resolver)
|
|
157
|
+
DMARCator.txt_records("_dmarc.example.com", resolver: resolver)
|
|
146
158
|
DMARCator.dmarc("example.com", resolver: resolver)
|
|
147
159
|
DMARCator::Record.lookup("example.com", resolver: resolver)
|
|
148
160
|
```
|
|
@@ -165,6 +177,22 @@ more than the lookup on a fast resolver); `Resolv::DNS` does not lock per query,
|
|
|
165
177
|
so use one resolver per thread when parallelising; and DoT/DoH open a new TLS
|
|
166
178
|
connection per lookup, so plaintext is the better choice for bulk work.
|
|
167
179
|
|
|
180
|
+
### SPF and DKIM
|
|
181
|
+
|
|
182
|
+
The TXT primitives also cover the other two records a checker needs. There is
|
|
183
|
+
no record-type-specific API, because the semantics belong to the caller:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
DMARCator.txt("example.com") # SPF (RFC 7208)
|
|
187
|
+
DMARCator.txt("s1._domainkey.example.com") # DKIM (RFC 6376)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Both need the raw list rather than a single value: more than one SPF record is
|
|
191
|
+
a `permerror`, and DKIM keys can be duplicated, rotated or revoked (`p=`). A
|
|
192
|
+
helper returning "the" record would hide exactly those conditions. DKIM is also
|
|
193
|
+
per-selector and selectors cannot be enumerated, so any convenience method would
|
|
194
|
+
have to take a selector and would only build the name above.
|
|
195
|
+
|
|
168
196
|
## Strict parsing
|
|
169
197
|
|
|
170
198
|
By default, unknown tags and tags with invalid values are ignored. Pass
|
data/lib/dmarcator/exceptions.rb
CHANGED
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'parslet'
|
|
4
|
-
|
|
5
3
|
module DMARCator
|
|
4
|
+
#
|
|
5
|
+
# Base class for every error raised by this library.
|
|
6
|
+
#
|
|
7
|
+
# Rescuing this catches parse failures, report failures and DNS lookup
|
|
8
|
+
# failures alike.
|
|
9
|
+
#
|
|
10
|
+
# @since 0.10.0
|
|
11
|
+
#
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
6
15
|
#
|
|
7
16
|
# Raised when a DMARC record cannot be parsed.
|
|
8
17
|
#
|
|
9
18
|
# @since 0.2.0
|
|
10
19
|
#
|
|
11
|
-
class InvalidRecord <
|
|
20
|
+
class InvalidRecord < Error
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
#
|
|
15
24
|
# Raised when a DMARC XML report cannot be parsed.
|
|
16
25
|
#
|
|
17
|
-
|
|
26
|
+
# @since 0.3.0
|
|
27
|
+
#
|
|
28
|
+
class InvalidReport < Error
|
|
18
29
|
end
|
|
19
30
|
|
|
20
31
|
#
|
|
21
|
-
# Raised when
|
|
32
|
+
# Raised when a DNS transport fails to return a usable answer.
|
|
22
33
|
#
|
|
23
34
|
# This is deliberately distinct from a missing record: a failed lookup must
|
|
24
35
|
# not be mistaken for a domain without a DMARC policy.
|
|
25
36
|
#
|
|
26
37
|
# @since 0.6.0
|
|
27
38
|
#
|
|
28
|
-
class ResolutionError <
|
|
39
|
+
class ResolutionError < Error
|
|
29
40
|
end
|
|
30
41
|
end
|
data/lib/dmarcator/query.rb
CHANGED
|
@@ -171,6 +171,39 @@ module DMARCator
|
|
|
171
171
|
# @since 0.8.0
|
|
172
172
|
#
|
|
173
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)
|
|
174
207
|
lookup_txt(validate_txt_name(name), resolver: resolver)
|
|
175
208
|
end
|
|
176
209
|
|
|
@@ -204,33 +237,25 @@ module DMARCator
|
|
|
204
237
|
|
|
205
238
|
# Per RFC 7489 §6.6.3, use the TXT record beginning with "v=DMARC1".
|
|
206
239
|
# If multiple DMARC records exist, treat the domain as having none.
|
|
207
|
-
dmarc_records = lookup_txt(name, resolver: resolver).
|
|
240
|
+
dmarc_records = lookup_txt(name, resolver: resolver).map do |record|
|
|
241
|
+
record.strings.join
|
|
242
|
+
end.select do |text|
|
|
208
243
|
text.start_with?('v=DMARC1')
|
|
209
244
|
end
|
|
210
245
|
|
|
211
246
|
dmarc_records.first if dmarc_records.one?
|
|
212
247
|
end
|
|
213
248
|
|
|
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
|
|
223
|
-
|
|
224
249
|
#
|
|
225
250
|
# Looks up the TXT records at an already-validated name.
|
|
226
251
|
#
|
|
227
|
-
# Shared by {txt} and {dmarc} so
|
|
252
|
+
# Shared by {txt}, {txt_records} and {dmarc} so they use one transport path.
|
|
228
253
|
#
|
|
229
254
|
# @param [String] name
|
|
230
255
|
#
|
|
231
256
|
# @param [Resolv::DNS, Resolver::DoH, Resolver::DoT] resolver
|
|
232
257
|
#
|
|
233
|
-
# @return [Array<
|
|
258
|
+
# @return [Array<Resolv::DNS::Resource::IN::TXT>]
|
|
234
259
|
#
|
|
235
260
|
# @raise [ResolutionError]
|
|
236
261
|
#
|
|
@@ -246,7 +271,7 @@ module DMARCator
|
|
|
246
271
|
raise ResolutionError, "DNS TXT lookup failed for #{name}: #{error.message}"
|
|
247
272
|
end
|
|
248
273
|
|
|
249
|
-
records
|
|
274
|
+
records
|
|
250
275
|
end
|
|
251
276
|
private_class_method :lookup_txt
|
|
252
277
|
end
|
data/lib/dmarcator/record.rb
CHANGED
|
@@ -285,7 +285,8 @@ module DMARCator
|
|
|
285
285
|
|
|
286
286
|
new(attributes)
|
|
287
287
|
rescue Parslet::ParseFailed => error
|
|
288
|
-
|
|
288
|
+
# Raising inside the rescue sets this error as the cause automatically.
|
|
289
|
+
raise InvalidRecord, truncate_error_message(error.message)
|
|
289
290
|
end
|
|
290
291
|
|
|
291
292
|
# Maximum length of error messages echoed from parsing untrusted input.
|
|
@@ -342,16 +343,6 @@ module DMARCator
|
|
|
342
343
|
end
|
|
343
344
|
end
|
|
344
345
|
|
|
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
|
-
|
|
355
346
|
#
|
|
356
347
|
# Converts the record to a Hash.
|
|
357
348
|
#
|
|
@@ -9,7 +9,10 @@ module DMARCator
|
|
|
9
9
|
#
|
|
10
10
|
# Represents a single `record` element of a DMARC XML report.
|
|
11
11
|
#
|
|
12
|
-
|
|
12
|
+
# Named `Row` so it is not confused with {DMARCator::Record}, the parsed
|
|
13
|
+
# DMARC TXT record.
|
|
14
|
+
#
|
|
15
|
+
class Row
|
|
13
16
|
include XmlHelper
|
|
14
17
|
|
|
15
18
|
# Attribute names exposed by this class.
|
data/lib/dmarcator/report.rb
CHANGED
|
@@ -8,7 +8,7 @@ require_relative 'xml_helper'
|
|
|
8
8
|
require_relative 'exceptions'
|
|
9
9
|
require_relative 'report/metadata'
|
|
10
10
|
require_relative 'report/policy'
|
|
11
|
-
require_relative 'report/
|
|
11
|
+
require_relative 'report/row'
|
|
12
12
|
|
|
13
13
|
module DMARCator
|
|
14
14
|
#
|
|
@@ -114,11 +114,11 @@ module DMARCator
|
|
|
114
114
|
#
|
|
115
115
|
# The report records.
|
|
116
116
|
#
|
|
117
|
-
# @return [Array<Report::
|
|
117
|
+
# @return [Array<Report::Row>]
|
|
118
118
|
#
|
|
119
119
|
def records
|
|
120
120
|
@records ||= @node.xpath('feedback/record').map do |node|
|
|
121
|
-
|
|
121
|
+
Row.new(node)
|
|
122
122
|
end
|
|
123
123
|
end
|
|
124
124
|
end
|
data/lib/dmarcator/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Noah Berman
|
|
@@ -78,7 +78,7 @@ files:
|
|
|
78
78
|
- lib/dmarcator/report/metadata.rb
|
|
79
79
|
- lib/dmarcator/report/policy.rb
|
|
80
80
|
- lib/dmarcator/report/reason.rb
|
|
81
|
-
- lib/dmarcator/report/
|
|
81
|
+
- lib/dmarcator/report/row.rb
|
|
82
82
|
- lib/dmarcator/resolver.rb
|
|
83
83
|
- lib/dmarcator/resolver/doh.rb
|
|
84
84
|
- lib/dmarcator/resolver/dot.rb
|