dmarcator 0.5.1 → 0.6.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 +61 -0
- data/lib/dmarcator/exceptions.rb +11 -0
- data/lib/dmarcator/query.rb +25 -4
- data/lib/dmarcator/record.rb +1 -1
- data/lib/dmarcator/resolver/doh.rb +115 -0
- data/lib/dmarcator/resolver/dot.rb +163 -0
- data/lib/dmarcator/resolver/encrypted.rb +131 -0
- data/lib/dmarcator/resolver.rb +76 -0
- data/lib/dmarcator/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50fb6579de6a2c481e5154bd28500cdb8c9800ba6897cd6aa76952c985531f22
|
|
4
|
+
data.tar.gz: d36d360f6d6cfe6e022cca51af4eec27c03349951c6259b486e178d31d2e32ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8aaea7f0723b6a2eb070345ba516e09b0b6a0391d35c19349eab9791b72ddc06b90f149706c8bf988f3c80784cfe47319577f3c10d190592c482c84320e4962d
|
|
7
|
+
data.tar.gz: bafee6506fdd045ff918bcbb89a7553fd761bcbbdd91eb274d737dc02d87b14014a5011566799f570197dbc0ffd71cc37872f3cf915eadeda8022c591e594b97
|
data/ChangeLog.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
### 0.6.0 / 2026-10-28
|
|
2
|
+
|
|
3
|
+
* Add DNS over HTTPS (RFC 8484) and DNS over TLS (RFC 7858) transports, selected with `DMARCATOR_DNS` (`doh` or `dot`). No provider is bundled and plaintext remains the default.
|
|
4
|
+
* Encrypted transports verify TLS certificates, validate the response id, question and TC bit, and follow CNAMEs up to a bounded depth.
|
|
5
|
+
* Add `DMARCator::ResolutionError`; encrypted transport failures raise instead of being reported as a missing record.
|
|
6
|
+
* Add `DMARCator.default_resolver` and `DMARCator::Resolver.build` for explicit transport configuration.
|
|
7
|
+
|
|
1
8
|
### 0.5.1 / 2026-10-28
|
|
2
9
|
|
|
3
10
|
* Point the gem metadata (`homepage`, `source_code_uri`, `bug_tracker_uri`) at the renamed GitHub repository (`dmarc` → `dmarcator`).
|
data/README.md
CHANGED
|
@@ -123,6 +123,43 @@ report.records.first.source_ip
|
|
|
123
123
|
# => "209.85.220.69"
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
## DNS transport
|
|
127
|
+
|
|
128
|
+
By default, DMARC lookups use plain DNS through `Resolv`. Encrypted transports
|
|
129
|
+
can be selected with environment variables; no DNS provider is baked in:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
# DNS over HTTPS (RFC 8484)
|
|
133
|
+
export DMARCATOR_DNS=doh
|
|
134
|
+
export DMARCATOR_DOH_URL=https://cloudflare-dns.com/dns-query
|
|
135
|
+
export DMARCATOR_DOH_IP=1.1.1.1 # optional: pin the endpoint
|
|
136
|
+
|
|
137
|
+
# DNS over TLS (RFC 7858)
|
|
138
|
+
export DMARCATOR_DNS=dot
|
|
139
|
+
export DMARCATOR_DOT_SERVER=1.1.1.1:853
|
|
140
|
+
export DMARCATOR_DOT_SNI=cloudflare-dns.com # optional: name to verify
|
|
141
|
+
|
|
142
|
+
# optional, both transports (seconds, default 5)
|
|
143
|
+
export DMARCATOR_DNS_TIMEOUT=5
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Certificates are always verified. `DMARCATOR_DOH_IP` pins the address so the
|
|
147
|
+
DoH endpoint does not have to be resolved first; TLS still verifies the URL's
|
|
148
|
+
hostname.
|
|
149
|
+
|
|
150
|
+
Encrypted transports fail closed: a transport failure raises
|
|
151
|
+
`DMARCator::ResolutionError` instead of being reported as a domain without a
|
|
152
|
+
DMARC record. Plaintext is the default because forcing a public resolver breaks
|
|
153
|
+
private and split-horizon zones, and some networks block port 853 or public
|
|
154
|
+
resolvers.
|
|
155
|
+
|
|
156
|
+
A resolver can also be built explicitly:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
resolver = DMARCator::Resolver::DoH.new(url: "https://dns.example/dns-query")
|
|
160
|
+
record = DMARCator::Record.query("example.com", resolver)
|
|
161
|
+
```
|
|
162
|
+
|
|
126
163
|
## Security
|
|
127
164
|
|
|
128
165
|
DMARC records and XML reports are treated as untrusted input:
|
|
@@ -156,6 +193,30 @@ To run the RSpec tests:
|
|
|
156
193
|
rake spec
|
|
157
194
|
```
|
|
158
195
|
|
|
196
|
+
## Releasing
|
|
197
|
+
|
|
198
|
+
Every published version must have an annotated tag pointing at the commit that
|
|
199
|
+
was built, so a gem can always be traced back to its source:
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
# 1. Bump VERSION in lib/dmarcator/version.rb and add a ChangeLog entry.
|
|
203
|
+
# 2. Commit the release.
|
|
204
|
+
git commit -am "Bump version to X.Y.Z"
|
|
205
|
+
|
|
206
|
+
# 3. Tag that commit before building anything.
|
|
207
|
+
git tag -a vX.Y.Z -m "dmarcator X.Y.Z"
|
|
208
|
+
|
|
209
|
+
# 4. Push the commit and its tags.
|
|
210
|
+
git push origin main --follow-tags
|
|
211
|
+
|
|
212
|
+
# 5. Build and publish from the tagged checkout.
|
|
213
|
+
gem build dmarc.gemspec
|
|
214
|
+
gem push dmarcator-X.Y.Z.gem
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Tag before publishing, never after: a tag without a published gem, or a gem
|
|
218
|
+
without a tag, makes the two hard to reconcile later.
|
|
219
|
+
|
|
159
220
|
## License
|
|
160
221
|
|
|
161
222
|
This project is released under the MIT License. See the [LICENSE.txt] file for
|
data/lib/dmarcator/exceptions.rb
CHANGED
|
@@ -11,4 +11,15 @@ module DMARCator
|
|
|
11
11
|
#
|
|
12
12
|
class InvalidReport < StandardError
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
#
|
|
16
|
+
# Raised when an encrypted DNS transport fails to return a usable answer.
|
|
17
|
+
#
|
|
18
|
+
# This is deliberately distinct from a missing record: a failed lookup must
|
|
19
|
+
# not be mistaken for a domain without a DMARC policy.
|
|
20
|
+
#
|
|
21
|
+
# @since 0.6.0
|
|
22
|
+
#
|
|
23
|
+
class ResolutionError < StandardError
|
|
24
|
+
end
|
|
14
25
|
end
|
data/lib/dmarcator/query.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'resolv'
|
|
4
4
|
require 'rubygems'
|
|
5
5
|
|
|
6
|
+
require_relative 'resolver'
|
|
7
|
+
|
|
6
8
|
module DMARCator
|
|
7
9
|
#
|
|
8
10
|
# Resolv gem versions affected by CVE-2026-80212 and CVE-2026-80213.
|
|
@@ -77,26 +79,45 @@ module DMARCator
|
|
|
77
79
|
domain
|
|
78
80
|
end
|
|
79
81
|
|
|
82
|
+
#
|
|
83
|
+
# Builds the resolver configured by the environment.
|
|
84
|
+
#
|
|
85
|
+
# @return [Resolv::DNS, Resolver::DoH, Resolver::DoT]
|
|
86
|
+
#
|
|
87
|
+
# @raise [ArgumentError]
|
|
88
|
+
# The configured transport or its settings are invalid.
|
|
89
|
+
#
|
|
90
|
+
# @since 0.6.0
|
|
91
|
+
#
|
|
92
|
+
def self.default_resolver
|
|
93
|
+
Resolver.build
|
|
94
|
+
end
|
|
95
|
+
|
|
80
96
|
#
|
|
81
97
|
# Queries a domain for the DMARC record.
|
|
82
98
|
#
|
|
83
99
|
# @param [String] domain
|
|
84
100
|
# The domain to query DMARC for.
|
|
85
101
|
#
|
|
86
|
-
# @param [Resolv::DNS] resolver
|
|
87
|
-
# The resolver to use.
|
|
102
|
+
# @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}.
|
|
88
105
|
#
|
|
89
106
|
# @return [String, nil]
|
|
90
107
|
# The domain's DMARC record or `nil` if none exists.
|
|
91
108
|
#
|
|
109
|
+
# @raise [Resolver::DoH, Resolver::DoT] resolution errors
|
|
110
|
+
# Encrypted transports raise {ResolutionError} when the lookup fails, so
|
|
111
|
+
# a transport problem is never reported as a missing record.
|
|
112
|
+
#
|
|
92
113
|
# @api semipublic
|
|
93
114
|
#
|
|
94
115
|
# @since 0.3.0
|
|
95
116
|
#
|
|
96
|
-
def self.query(domain,resolver=
|
|
117
|
+
def self.query(domain,resolver=default_resolver)
|
|
97
118
|
validate_domain(domain)
|
|
98
119
|
|
|
99
|
-
resolver.timeouts = DNS_TIMEOUTS
|
|
120
|
+
resolver.timeouts = DNS_TIMEOUTS if resolver.respond_to?(:timeouts=)
|
|
100
121
|
|
|
101
122
|
host = "_dmarc.#{domain}"
|
|
102
123
|
|
data/lib/dmarcator/record.rb
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative '../exceptions'
|
|
8
|
+
require_relative 'encrypted'
|
|
9
|
+
|
|
10
|
+
module DMARCator
|
|
11
|
+
module Resolver
|
|
12
|
+
#
|
|
13
|
+
# DNS over HTTPS transport ([RFC 8484]).
|
|
14
|
+
#
|
|
15
|
+
# The endpoint is configured by the caller; no provider is assumed. To avoid
|
|
16
|
+
# needing DNS to reach the DNS server, pass `ipaddr:` (or set
|
|
17
|
+
# `DMARCATOR_DOH_IP`) to pin the connection while TLS still verifies the
|
|
18
|
+
# certificate against the URL's hostname.
|
|
19
|
+
#
|
|
20
|
+
# [RFC 8484]: https://www.rfc-editor.org/rfc/rfc8484
|
|
21
|
+
#
|
|
22
|
+
# @since 0.6.0
|
|
23
|
+
#
|
|
24
|
+
class DoH < Encrypted
|
|
25
|
+
# Default connect/read timeout in seconds.
|
|
26
|
+
DEFAULT_TIMEOUT = 5
|
|
27
|
+
|
|
28
|
+
# @return [URI::HTTPS] the DoH endpoint.
|
|
29
|
+
attr_reader :url
|
|
30
|
+
|
|
31
|
+
# @return [String, nil] the address to connect to instead of resolving
|
|
32
|
+
# the endpoint hostname.
|
|
33
|
+
attr_reader :ipaddr
|
|
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 [DoH]
|
|
44
|
+
#
|
|
45
|
+
# @raise [ArgumentError]
|
|
46
|
+
# `DMARCATOR_DOH_URL` is missing or not an HTTPS URL.
|
|
47
|
+
#
|
|
48
|
+
def self.from_env(env = ENV)
|
|
49
|
+
url = env['DMARCATOR_DOH_URL']
|
|
50
|
+
|
|
51
|
+
if url.nil? || url.empty?
|
|
52
|
+
raise ArgumentError, 'DMARCATOR_DNS=doh requires DMARCATOR_DOH_URL'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
new(
|
|
56
|
+
url: url,
|
|
57
|
+
ipaddr: presence(env['DMARCATOR_DOH_IP']),
|
|
58
|
+
timeout: timeout_from(env)
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.presence(value)
|
|
63
|
+
value unless value.nil? || value.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.timeout_from(env)
|
|
67
|
+
value = env['DMARCATOR_DNS_TIMEOUT']
|
|
68
|
+
|
|
69
|
+
value.nil? || value.empty? ? DEFAULT_TIMEOUT : Integer(value, 10)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(url:, ipaddr: nil, timeout: DEFAULT_TIMEOUT)
|
|
73
|
+
@url = URI.parse(url)
|
|
74
|
+
|
|
75
|
+
unless @url.is_a?(URI::HTTPS) && @url.host
|
|
76
|
+
raise ArgumentError, "DoH endpoint must be an https URL: #{url.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@ipaddr = ipaddr
|
|
80
|
+
@timeout = timeout
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def exchange(query)
|
|
86
|
+
http = Net::HTTP.new(@url.host, @url.port)
|
|
87
|
+
http.use_ssl = true
|
|
88
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
89
|
+
http.open_timeout = @timeout
|
|
90
|
+
http.read_timeout = @timeout
|
|
91
|
+
http.write_timeout = @timeout
|
|
92
|
+
http.ipaddr = @ipaddr if @ipaddr
|
|
93
|
+
|
|
94
|
+
request = Net::HTTP::Post.new(@url.request_uri)
|
|
95
|
+
request['content-type'] = 'application/dns-message'
|
|
96
|
+
request['accept'] = 'application/dns-message'
|
|
97
|
+
request.body = query.encode
|
|
98
|
+
|
|
99
|
+
response = http.start { |connection| connection.request(request) }
|
|
100
|
+
|
|
101
|
+
status = response.code.to_i
|
|
102
|
+
|
|
103
|
+
unless status.between?(200, 299)
|
|
104
|
+
raise ResolutionError, "DoH endpoint returned HTTP #{status}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
response.body.to_s.b
|
|
108
|
+
rescue ResolutionError
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError => error
|
|
111
|
+
raise ResolutionError, "DoH request failed: #{error.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'socket'
|
|
6
|
+
|
|
7
|
+
require_relative '../exceptions'
|
|
8
|
+
require_relative 'encrypted'
|
|
9
|
+
|
|
10
|
+
module DMARCator
|
|
11
|
+
module Resolver
|
|
12
|
+
#
|
|
13
|
+
# DNS over TLS transport ([RFC 7858]).
|
|
14
|
+
#
|
|
15
|
+
# The server is configured by the caller; no provider is assumed. When the
|
|
16
|
+
# server is an IP literal, TLS still verifies the certificate against that
|
|
17
|
+
# address, and `sni:` can name a hostname to verify instead.
|
|
18
|
+
#
|
|
19
|
+
# [RFC 7858]: https://www.rfc-editor.org/rfc/rfc7858
|
|
20
|
+
#
|
|
21
|
+
# @since 0.6.0
|
|
22
|
+
#
|
|
23
|
+
class DoT < Encrypted
|
|
24
|
+
# Default DoT port.
|
|
25
|
+
DEFAULT_PORT = 853
|
|
26
|
+
|
|
27
|
+
# Default connect/read timeout in seconds.
|
|
28
|
+
DEFAULT_TIMEOUT = 5
|
|
29
|
+
|
|
30
|
+
# @return [String] the server host or address.
|
|
31
|
+
attr_reader :host
|
|
32
|
+
|
|
33
|
+
# @return [Integer] the server port.
|
|
34
|
+
attr_reader :port
|
|
35
|
+
|
|
36
|
+
# @return [String] the name to verify the certificate against.
|
|
37
|
+
attr_reader :sni
|
|
38
|
+
|
|
39
|
+
# @return [Integer] connect/read timeout in seconds.
|
|
40
|
+
attr_reader :timeout
|
|
41
|
+
|
|
42
|
+
#
|
|
43
|
+
# Builds a resolver from environment variables.
|
|
44
|
+
#
|
|
45
|
+
# @param [Hash] env
|
|
46
|
+
#
|
|
47
|
+
# @return [DoT]
|
|
48
|
+
#
|
|
49
|
+
# @raise [ArgumentError]
|
|
50
|
+
# `DMARCATOR_DOT_SERVER` is missing or malformed.
|
|
51
|
+
#
|
|
52
|
+
def self.from_env(env = ENV)
|
|
53
|
+
server = env['DMARCATOR_DOT_SERVER']
|
|
54
|
+
|
|
55
|
+
if server.nil? || server.empty?
|
|
56
|
+
raise ArgumentError, 'DMARCATOR_DNS=dot requires DMARCATOR_DOT_SERVER (host[:port])'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
host, port = split_server(server)
|
|
60
|
+
sni = env['DMARCATOR_DOT_SNI']
|
|
61
|
+
|
|
62
|
+
new(
|
|
63
|
+
host: host,
|
|
64
|
+
port: port,
|
|
65
|
+
sni: sni.nil? || sni.empty? ? nil : sni,
|
|
66
|
+
timeout: timeout_from(env)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#
|
|
71
|
+
# Splits `host`, `host:port`, `[v6]`, or `[v6]:port`.
|
|
72
|
+
#
|
|
73
|
+
# @param [String] server
|
|
74
|
+
#
|
|
75
|
+
# @return [Array(String, Integer)]
|
|
76
|
+
#
|
|
77
|
+
# @raise [ArgumentError]
|
|
78
|
+
# The value is malformed.
|
|
79
|
+
#
|
|
80
|
+
def self.split_server(server)
|
|
81
|
+
if (match = /\A\[(?<host>[^\]]+)\](?::(?<port>\d+))?\z/.match(server))
|
|
82
|
+
[match[:host], (match[:port] || DEFAULT_PORT).to_i]
|
|
83
|
+
elsif (match = /\A(?<host>[^:]+)(?::(?<port>\d+))?\z/.match(server))
|
|
84
|
+
[match[:host], (match[:port] || DEFAULT_PORT).to_i]
|
|
85
|
+
else
|
|
86
|
+
raise ArgumentError, "invalid DMARCATOR_DOT_SERVER: #{server.inspect}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.timeout_from(env)
|
|
91
|
+
value = env['DMARCATOR_DNS_TIMEOUT']
|
|
92
|
+
|
|
93
|
+
value.nil? || value.empty? ? DEFAULT_TIMEOUT : Integer(value, 10)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(host:, port: DEFAULT_PORT, sni: nil, timeout: DEFAULT_TIMEOUT)
|
|
97
|
+
@host = host
|
|
98
|
+
@port = port
|
|
99
|
+
@sni = sni || host
|
|
100
|
+
@timeout = timeout
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def exchange(query)
|
|
106
|
+
wire = query.encode
|
|
107
|
+
|
|
108
|
+
with_tls do |socket|
|
|
109
|
+
socket.write([wire.bytesize].pack('n') + wire)
|
|
110
|
+
|
|
111
|
+
header = socket.read(2)
|
|
112
|
+
|
|
113
|
+
raise ResolutionError, 'short DoT response' if header.nil? || header.bytesize < 2
|
|
114
|
+
|
|
115
|
+
body = socket.read(header.unpack1('n'))
|
|
116
|
+
raise ResolutionError, 'short DoT response' if body.nil?
|
|
117
|
+
|
|
118
|
+
body
|
|
119
|
+
end
|
|
120
|
+
rescue ResolutionError
|
|
121
|
+
raise
|
|
122
|
+
rescue StandardError => error
|
|
123
|
+
raise ResolutionError, "DoT request failed: #{error.message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def with_tls
|
|
127
|
+
tcp = Socket.tcp(@host, @port, connect_timeout: @timeout)
|
|
128
|
+
tcp.timeout = @timeout if tcp.respond_to?(:timeout=)
|
|
129
|
+
|
|
130
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
131
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
132
|
+
context.set_params
|
|
133
|
+
|
|
134
|
+
socket = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
135
|
+
socket.hostname = @sni unless ip_literal?(@sni)
|
|
136
|
+
socket.sync_close = true
|
|
137
|
+
socket.connect
|
|
138
|
+
socket.post_connection_check(@sni)
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
yield socket
|
|
142
|
+
ensure
|
|
143
|
+
close_quietly(socket)
|
|
144
|
+
close_quietly(tcp)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ip_literal?(value)
|
|
149
|
+
IPAddr.new(value.to_s)
|
|
150
|
+
|
|
151
|
+
true
|
|
152
|
+
rescue IPAddr::Error, ArgumentError
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def close_quietly(io)
|
|
157
|
+
io.close
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'resolv'
|
|
4
|
+
|
|
5
|
+
require_relative '../exceptions'
|
|
6
|
+
|
|
7
|
+
module DMARCator
|
|
8
|
+
module Resolver
|
|
9
|
+
#
|
|
10
|
+
# Base class for encrypted DNS transports (DoH and DoT).
|
|
11
|
+
#
|
|
12
|
+
# Subclasses implement {#exchange}, which sends one encoded DNS query and
|
|
13
|
+
# returns the encoded response. Everything else - query construction,
|
|
14
|
+
# response validation and CNAME following - is shared here.
|
|
15
|
+
#
|
|
16
|
+
# @abstract
|
|
17
|
+
#
|
|
18
|
+
# @since 0.6.0
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
#
|
|
22
|
+
class Encrypted
|
|
23
|
+
# Maximum number of CNAME indirections to follow.
|
|
24
|
+
MAX_CNAME_DEPTH = 8
|
|
25
|
+
|
|
26
|
+
#
|
|
27
|
+
# Looks up resource records, matching `Resolv::DNS#getresources`.
|
|
28
|
+
#
|
|
29
|
+
# @param [String, Resolv::DNS::Name] name
|
|
30
|
+
# The name to look up.
|
|
31
|
+
#
|
|
32
|
+
# @param [Class] type
|
|
33
|
+
# The resource record type, e.g.
|
|
34
|
+
# `Resolv::DNS::Resource::IN::TXT`.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<Resolv::DNS::Resource>]
|
|
37
|
+
#
|
|
38
|
+
# @raise [ResolutionError]
|
|
39
|
+
# The transport failed or returned an unusable response.
|
|
40
|
+
#
|
|
41
|
+
def getresources(name, type)
|
|
42
|
+
fetch(dns_name(name), type, MAX_CNAME_DEPTH)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def dns_name(name)
|
|
48
|
+
return name if name.is_a?(Resolv::DNS::Name)
|
|
49
|
+
|
|
50
|
+
Resolv::DNS::Name.create(name.to_s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fetch(name, type, depth)
|
|
54
|
+
id = Random.rand(0x10000)
|
|
55
|
+
message = decode_response(exchange(build_query(id, name, type)), id, name, type)
|
|
56
|
+
|
|
57
|
+
case message.rcode
|
|
58
|
+
when Resolv::DNS::RCode::NoError
|
|
59
|
+
collect(message, name, type, depth)
|
|
60
|
+
when Resolv::DNS::RCode::NXDomain
|
|
61
|
+
[]
|
|
62
|
+
else
|
|
63
|
+
raise ResolutionError, "DNS query failed with rcode #{message.rcode}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect(message, name, type, depth)
|
|
68
|
+
matching = message.answer.select { |_name, _ttl, record| record.is_a?(type) }
|
|
69
|
+
return matching.map { |_name, _ttl, record| record } unless matching.empty?
|
|
70
|
+
|
|
71
|
+
cname = message.answer.find do |_name, _ttl, record|
|
|
72
|
+
record.is_a?(Resolv::DNS::Resource::IN::CNAME)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return fetch(cname[2].name, type, depth - 1) if cname && depth.positive?
|
|
76
|
+
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_query(id, name, type)
|
|
81
|
+
message = Resolv::DNS::Message.new(id)
|
|
82
|
+
message.rd = 1
|
|
83
|
+
message.add_question(name, type)
|
|
84
|
+
message
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def decode_response(wire, id, name, type)
|
|
88
|
+
raise ResolutionError, 'empty DNS response' if wire.nil? || wire.empty?
|
|
89
|
+
|
|
90
|
+
message = begin
|
|
91
|
+
Resolv::DNS::Message.decode(wire)
|
|
92
|
+
rescue StandardError => error
|
|
93
|
+
raise ResolutionError, "malformed DNS response: #{error.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
unless message.id == id
|
|
97
|
+
raise ResolutionError, 'unexpected DNS response (transaction mismatch)'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# `Message.decode` returns early for truncated responses, leaving the
|
|
101
|
+
# remaining header fields unset, so check the TC bit first.
|
|
102
|
+
raise ResolutionError, 'truncated DNS response' unless message.tc.to_i.zero?
|
|
103
|
+
|
|
104
|
+
unless message.qr == 1
|
|
105
|
+
raise ResolutionError, 'unexpected DNS response (not a response)'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
question = message.question.first
|
|
109
|
+
|
|
110
|
+
unless question && same_name?(question[0], name) && question[1] == type
|
|
111
|
+
raise ResolutionError, 'unexpected DNS response (question mismatch)'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
message
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
#
|
|
118
|
+
# Compares DNS names case-insensitively and ignoring a trailing root dot.
|
|
119
|
+
# `Resolv::DNS::Name#==` treats `example.com` and `example.com.` as
|
|
120
|
+
# different, but a decoded response always carries the absolute form.
|
|
121
|
+
#
|
|
122
|
+
def same_name?(left, right)
|
|
123
|
+
normalize_name(left) == normalize_name(right)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize_name(value)
|
|
127
|
+
value.to_s.downcase.chomp('.')
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'resolv'
|
|
4
|
+
|
|
5
|
+
require_relative 'exceptions'
|
|
6
|
+
require_relative 'resolver/encrypted'
|
|
7
|
+
require_relative 'resolver/doh'
|
|
8
|
+
require_relative 'resolver/dot'
|
|
9
|
+
|
|
10
|
+
module DMARCator
|
|
11
|
+
#
|
|
12
|
+
# Selects the DNS transport used for DMARC lookups.
|
|
13
|
+
#
|
|
14
|
+
# The transport is chosen with the `DMARCATOR_DNS` environment variable, or
|
|
15
|
+
# explicitly via {.build}:
|
|
16
|
+
#
|
|
17
|
+
# DMARCATOR_DNS=plaintext # default: Resolv::DNS (UDP/TCP)
|
|
18
|
+
# DMARCATOR_DNS=doh # requires DMARCATOR_DOH_URL
|
|
19
|
+
# DMARCATOR_DNS=dot # requires DMARCATOR_DOT_SERVER
|
|
20
|
+
#
|
|
21
|
+
# Encrypted transports fail closed: a transport failure raises
|
|
22
|
+
# {ResolutionError} instead of being reported as "no DMARC record".
|
|
23
|
+
#
|
|
24
|
+
# @since 0.6.0
|
|
25
|
+
#
|
|
26
|
+
module Resolver
|
|
27
|
+
# Supported transport names.
|
|
28
|
+
MODES = %w[plaintext doh dot].freeze
|
|
29
|
+
|
|
30
|
+
# Transport used when `DMARCATOR_DNS` is unset.
|
|
31
|
+
DEFAULT_MODE = 'plaintext'
|
|
32
|
+
|
|
33
|
+
#
|
|
34
|
+
# Reads the configured transport name.
|
|
35
|
+
#
|
|
36
|
+
# @param [Hash] env
|
|
37
|
+
#
|
|
38
|
+
# @return [String]
|
|
39
|
+
#
|
|
40
|
+
def self.mode(env = ENV)
|
|
41
|
+
value = env['DMARCATOR_DNS']
|
|
42
|
+
|
|
43
|
+
value.nil? || value.empty? ? DEFAULT_MODE : value.to_s.strip.downcase
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#
|
|
47
|
+
# Builds the configured resolver.
|
|
48
|
+
#
|
|
49
|
+
# @param [String, nil] mode
|
|
50
|
+
# Overrides the `DMARCATOR_DNS` environment variable.
|
|
51
|
+
#
|
|
52
|
+
# @param [Hash] env
|
|
53
|
+
#
|
|
54
|
+
# @return [Resolv::DNS, DoH, DoT]
|
|
55
|
+
#
|
|
56
|
+
# @raise [ArgumentError]
|
|
57
|
+
# The mode or its required settings are invalid.
|
|
58
|
+
#
|
|
59
|
+
def self.build(mode: nil, env: ENV)
|
|
60
|
+
name = (mode || self.mode(env)).to_s.strip.downcase
|
|
61
|
+
|
|
62
|
+
case name
|
|
63
|
+
when 'plaintext'
|
|
64
|
+
Resolv::DNS.new
|
|
65
|
+
when 'doh'
|
|
66
|
+
DoH.from_env(env)
|
|
67
|
+
when 'dot'
|
|
68
|
+
DoT.from_env(env)
|
|
69
|
+
else
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"unknown DMARCATOR_DNS value: #{name.inspect} " \
|
|
72
|
+
"(expected one of #{MODES.join(', ')})"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Noah Berman
|
|
@@ -79,6 +79,10 @@ files:
|
|
|
79
79
|
- lib/dmarcator/report/policy.rb
|
|
80
80
|
- lib/dmarcator/report/reason.rb
|
|
81
81
|
- lib/dmarcator/report/record.rb
|
|
82
|
+
- lib/dmarcator/resolver.rb
|
|
83
|
+
- lib/dmarcator/resolver/doh.rb
|
|
84
|
+
- lib/dmarcator/resolver/dot.rb
|
|
85
|
+
- lib/dmarcator/resolver/encrypted.rb
|
|
82
86
|
- lib/dmarcator/uri.rb
|
|
83
87
|
- lib/dmarcator/version.rb
|
|
84
88
|
- lib/dmarcator/xml_helper.rb
|