dmarcator 0.5.1 → 0.6.1

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: d474c30401fa6eddca63e996bfbc7bf2726930386282d12aad8fbbe3f67353bf
4
- data.tar.gz: eca8a030b0b6abf0a2ceeaa9c446843f502ec8132f4d409c386e639806e7db69
3
+ metadata.gz: ab57f0c6599df86ed3331199b84f0abdd9aae8876405e4205f2070b9e457b5ca
4
+ data.tar.gz: 5466098000ff91848f6cb8208606ed8b3312eb9b476763074d917b3f7f377aca
5
5
  SHA512:
6
- metadata.gz: 0c20cb2c3576c6acce7619b5c20ac0ea3b335a0ae14c6e3f1cb0876f09eb232d559dad8ce9197923c1c0b33828ed1c8e3d63437c7ddc1670828bad8ae0fdf8cf
7
- data.tar.gz: bf4fac1cb1c98fcd597424ad913b1e2eb02e5e1fb93f985b21c4a5cbbc8fae87b29aaa83ff73b6edc2c9c40f468be712554a3c6d61ff61e592cbe215d0188805
6
+ metadata.gz: 71c83a39fc17926997e43465ef07210a7a062a67b922462623e4d6c0daa617acefc4ec2546194ffa2f6755bf62321504134140b5e35813ac9e34b8ed43abbf89
7
+ data.tar.gz: ebdc8623cdba9d7632188a52fbdc203d7c1f85efed580f72ce5ceebeafd8a7c5eae4f5f0797d8960b1a6fb5e1b2af9aeb9d5484ffc74a5f8adc4942af4de286a
data/ChangeLog.md CHANGED
@@ -1,3 +1,18 @@
1
+ ### 0.6.1 / 2026-10-29
2
+
3
+ * Remove a redundant `attr_reader :sp` that `Record#sp` already overrode, fixing a `method redefined; discarding old sp` warning emitted under `-w` (as CI enables).
4
+ * Add tests covering `Record#sp` falling back to `p` when `sp` is omitted.
5
+ * Document every report accessor, constant, and public class that YARD previously omitted; documentation coverage is now 100% with no warnings.
6
+ * Note the encrypted-transport guarantees in the README security section.
7
+ * No functional changes.
8
+
9
+ ### 0.6.0 / 2026-10-28
10
+
11
+ * 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.
12
+ * Encrypted transports verify TLS certificates, validate the response id, question and TC bit, and follow CNAMEs up to a bounded depth.
13
+ * Add `DMARCator::ResolutionError`; encrypted transport failures raise instead of being reported as a missing record.
14
+ * Add `DMARCator.default_resolver` and `DMARCator::Resolver.build` for explicit transport configuration.
15
+
1
16
  ### 0.5.1 / 2026-10-28
2
17
 
3
18
  * 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:
@@ -140,6 +177,11 @@ DMARC records and XML reports are treated as untrusted input:
140
177
  CVE-2026-80212/CVE-2026-80213.
141
178
  * Domain names are validated before resolution (labels ≤ 63 octets, total ≤
142
179
  248 octets, no leading/trailing hyphens).
180
+ * Optional [DNS over HTTPS and DNS over TLS](#dns-transport) transports verify
181
+ TLS certificates and fail closed: a transport failure raises
182
+ `DMARCator::ResolutionError` rather than being reported as a domain without a
183
+ DMARC record, so a blocked or downgraded lookup can never silently enforce no
184
+ policy.
143
185
 
144
186
  ## Requirements
145
187
 
@@ -156,6 +198,30 @@ To run the RSpec tests:
156
198
  rake spec
157
199
  ```
158
200
 
201
+ ## Releasing
202
+
203
+ Every published version must have an annotated tag pointing at the commit that
204
+ was built, so a gem can always be traced back to its source:
205
+
206
+ ```sh
207
+ # 1. Bump VERSION in lib/dmarcator/version.rb and add a ChangeLog entry.
208
+ # 2. Commit the release.
209
+ git commit -am "Bump version to X.Y.Z"
210
+
211
+ # 3. Tag that commit before building anything.
212
+ git tag -a vX.Y.Z -m "dmarcator X.Y.Z"
213
+
214
+ # 4. Push the commit and its tags.
215
+ git push origin main --follow-tags
216
+
217
+ # 5. Build and publish from the tagged checkout.
218
+ gem build dmarc.gemspec
219
+ gem push dmarcator-X.Y.Z.gem
220
+ ```
221
+
222
+ Tag before publishing, never after: a tag without a published gem, or a gem
223
+ without a tag, makes the two hard to reconcile later.
224
+
159
225
  ## License
160
226
 
161
227
  This project is released under the MIT License. See the [LICENSE.txt] file for
@@ -3,6 +3,11 @@
3
3
  require 'parslet'
4
4
 
5
5
  module DMARCator
6
+ #
7
+ # Raised when a DMARC record cannot be parsed.
8
+ #
9
+ # @since 0.2.0
10
+ #
6
11
  class InvalidRecord < Parslet::ParseFailed
7
12
  end
8
13
 
@@ -11,4 +16,15 @@ module DMARCator
11
16
  #
12
17
  class InvalidReport < StandardError
13
18
  end
19
+
20
+ #
21
+ # Raised when an encrypted DNS transport fails to return a usable answer.
22
+ #
23
+ # This is deliberately distinct from a missing record: a failed lookup must
24
+ # not be mistaken for a domain without a DMARC policy.
25
+ #
26
+ # @since 0.6.0
27
+ #
28
+ class ResolutionError < StandardError
29
+ end
14
30
  end
@@ -50,6 +50,21 @@ module DMARCator
50
50
  unknown_tag
51
51
  end
52
52
 
53
+ #
54
+ # Defines a rule for a `tag=value` DMARC tag, capturing values that do not
55
+ # match the rule as `:invalid_tag` so strict mode can reject them.
56
+ #
57
+ # @param [Symbol] name
58
+ # The rule name, used as `dmarc_<name>`.
59
+ #
60
+ # @param [String] tag
61
+ # The DMARC tag name.
62
+ #
63
+ # @yieldreturn [Parslet::Atoms::Base]
64
+ # The parslet matching the tag's value.
65
+ #
66
+ # @api private
67
+ #
53
68
  def self.tag_rule(name,tag,&block)
54
69
  rule(:"dmarc_#{name}") do
55
70
  (
@@ -123,6 +138,11 @@ module DMARCator
123
138
  rule(:wsp) { str(' ') | str("\t") }
124
139
  rule(:wsp?) { wsp.repeat }
125
140
 
141
+ #
142
+ # Converts the raw parse tree into tag attributes.
143
+ #
144
+ # @api private
145
+ #
126
146
  class Transform < Parslet::Transform
127
147
 
128
148
  rule(fo_opt: simple(:fo_opt)) { fo_opt }
@@ -155,8 +175,21 @@ module DMARCator
155
175
  rule(rua: subtree(:uris)) { {rua: Array(uris)} }
156
176
  rule(ruf: subtree(:uris)) { {ruf: Array(uris)} }
157
177
 
178
+ # Maximum length of error messages echoed from untrusted input.
158
179
  MAX_ERROR_MESSAGE_LENGTH = 300
159
180
 
181
+ #
182
+ # Parses a `mailto:` URI from a parse tree slice.
183
+ #
184
+ # @param [Parslet::Slice] value
185
+ #
186
+ # @return [URI::MailTo]
187
+ #
188
+ # @raise [InvalidRecord]
189
+ # The URI is not a valid `mailto:` URI.
190
+ #
191
+ # @api private
192
+ #
160
193
  def self.parse_uri(value)
161
194
  uri = value.to_s
162
195
 
@@ -173,6 +206,15 @@ module DMARCator
173
206
  parsed
174
207
  end
175
208
 
209
+ #
210
+ # Truncates an error message to a bounded length.
211
+ #
212
+ # @param [String] message
213
+ #
214
+ # @return [String]
215
+ #
216
+ # @api private
217
+ #
176
218
  def self.truncate(message)
177
219
  message = message.to_s
178
220
 
@@ -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. Its timeouts are set to {DNS_TIMEOUTS}.
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=Resolv::DNS.new)
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
 
@@ -7,10 +7,15 @@ require 'dmarcator/exceptions'
7
7
  require 'resolv'
8
8
 
9
9
  module DMARCator
10
+ #
11
+ # A parsed DMARC record, as published in a `_dmarc.<domain>` TXT record.
12
+ #
13
+ # @see https://www.rfc-editor.org/rfc/rfc7489
14
+ #
10
15
  class Record
11
16
 
12
17
  # `p` field.
13
- #
18
+ #
14
19
  # @return [:none, :quarantine, :reject]
15
20
  attr_reader :p
16
21
 
@@ -24,11 +29,6 @@ module DMARCator
24
29
  # @return [Array<Uri>]
25
30
  attr_reader :ruf
26
31
 
27
- # `sp` field.
28
- #
29
- # @return [:none, :quarantine, :reject]
30
- attr_reader :sp
31
-
32
32
  # `v` field.
33
33
  #
34
34
  # @return [:DMARC1]
@@ -338,7 +338,7 @@ module DMARCator
338
338
  #
339
339
  # @api public
340
340
  #
341
- def self.query(domain,resolver=Resolv::DNS.new)
341
+ def self.query(domain,resolver=DMARCator.default_resolver)
342
342
  if (dmarc = DMARCator.query(domain,resolver))
343
343
  parse(dmarc)
344
344
  end
@@ -10,9 +10,20 @@ module DMARCator
10
10
  class AuthResult
11
11
  include XmlHelper
12
12
 
13
+ # Attribute names exposed by this class.
13
14
  ATTRIBUTES = [:type, :domain, :result, :selector, :scope]
14
15
 
15
- attr_accessor(*ATTRIBUTES)
16
+ # @!attribute [rw] type
17
+ # @return [String] the entry name (`dkim` or `spf`).
18
+ # @!attribute [rw] domain
19
+ # @return [String, nil] the authenticated domain.
20
+ # @!attribute [rw] result
21
+ # @return [String, nil] the authentication result.
22
+ # @!attribute [rw] selector
23
+ # @return [String, nil] the DKIM selector.
24
+ # @!attribute [rw] scope
25
+ # @return [String, nil] the SPF scope.
26
+ ATTRIBUTES.each { |attribute| attr_accessor(attribute) }
16
27
 
17
28
  def initialize(node)
18
29
  @node = node
@@ -10,12 +10,27 @@ module DMARCator
10
10
  class Metadata
11
11
  include XmlHelper
12
12
 
13
+ # Attribute names exposed by this class.
13
14
  ATTRIBUTES = [
14
15
  :org_name, :email, :extra_contact_info, :report_id,
15
16
  :begin_at, :end_at, :errors
16
17
  ]
17
18
 
18
- attr_accessor(*ATTRIBUTES)
19
+ # @!attribute [rw] org_name
20
+ # @return [String, nil] the reporting organization.
21
+ # @!attribute [rw] email
22
+ # @return [String, nil] the reporting organization's contact address.
23
+ # @!attribute [rw] extra_contact_info
24
+ # @return [String, nil] additional contact information.
25
+ # @!attribute [rw] report_id
26
+ # @return [String, nil] the unique report identifier.
27
+ # @!attribute [rw] begin_at
28
+ # @return [Time, nil] the start of the reporting interval.
29
+ # @!attribute [rw] end_at
30
+ # @return [Time, nil] the end of the reporting interval.
31
+ # @!attribute [rw] errors
32
+ # @return [Array<String>] errors reported by the reporting organization.
33
+ ATTRIBUTES.each { |attribute| attr_accessor(attribute) }
19
34
 
20
35
  def initialize(node)
21
36
  @node = node
@@ -10,9 +10,24 @@ module DMARCator
10
10
  class Policy
11
11
  include XmlHelper
12
12
 
13
+ # Attribute names exposed by this class.
13
14
  ATTRIBUTES = [:domain, :adkim, :aspf, :p, :sp, :pct, :fo]
14
15
 
15
- attr_accessor(*ATTRIBUTES)
16
+ # @!attribute [rw] domain
17
+ # @return [String, nil] the domain the policy applies to.
18
+ # @!attribute [rw] adkim
19
+ # @return [String, nil] the DKIM alignment mode (`r` or `s`).
20
+ # @!attribute [rw] aspf
21
+ # @return [String, nil] the SPF alignment mode (`r` or `s`).
22
+ # @!attribute [rw] p
23
+ # @return [String, nil] the requested policy.
24
+ # @!attribute [rw] sp
25
+ # @return [String, nil] the requested subdomain policy.
26
+ # @!attribute [rw] pct
27
+ # @return [Integer, nil] the percentage of messages the policy applied to.
28
+ # @!attribute [rw] fo
29
+ # @return [String, nil] the requested failure-reporting options.
30
+ ATTRIBUTES.each { |attribute| attr_accessor(attribute) }
16
31
 
17
32
  def initialize(node)
18
33
  @node = node
@@ -10,9 +10,14 @@ module DMARCator
10
10
  class Reason
11
11
  include XmlHelper
12
12
 
13
+ # Attribute names exposed by this class.
13
14
  ATTRIBUTES = [:type, :comment]
14
15
 
15
- attr_accessor(*ATTRIBUTES)
16
+ # @!attribute [rw] type
17
+ # @return [String, nil] the reason type.
18
+ # @!attribute [rw] comment
19
+ # @return [String, nil] an optional explanatory comment.
20
+ ATTRIBUTES.each { |attribute| attr_accessor(attribute) }
16
21
 
17
22
  def initialize(node)
18
23
  @node = node
@@ -12,12 +12,29 @@ module DMARCator
12
12
  class Record
13
13
  include XmlHelper
14
14
 
15
+ # Attribute names exposed by this class.
15
16
  ATTRIBUTES = [
16
17
  :source_ip, :count, :disposition, :dkim, :spf,
17
18
  :envelope_to, :envelope_from, :header_from
18
19
  ]
19
20
 
20
- attr_accessor(*ATTRIBUTES)
21
+ # @!attribute [rw] source_ip
22
+ # @return [String, nil] the sending IP address.
23
+ # @!attribute [rw] count
24
+ # @return [Integer, nil] the number of messages from this source.
25
+ # @!attribute [rw] disposition
26
+ # @return [String, nil] the action applied to the messages.
27
+ # @!attribute [rw] dkim
28
+ # @return [String, nil] the DKIM evaluation result.
29
+ # @!attribute [rw] spf
30
+ # @return [String, nil] the SPF evaluation result.
31
+ # @!attribute [rw] envelope_to
32
+ # @return [String, nil] the envelope recipient domain.
33
+ # @!attribute [rw] envelope_from
34
+ # @return [String, nil] the envelope sender domain.
35
+ # @!attribute [rw] header_from
36
+ # @return [String, nil] the `From` header domain.
37
+ ATTRIBUTES.each { |attribute| attr_accessor(attribute) }
21
38
  attr_writer :auth_results, :reasons
22
39
 
23
40
  def initialize(node)
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DMARCator
4
4
  # dmarcator version
5
- VERSION = '0.5.1'
5
+ VERSION = '0.6.1'
6
6
  end
data/lib/dmarcator.rb CHANGED
@@ -6,3 +6,15 @@ require 'dmarcator/record'
6
6
  require 'dmarcator/parser'
7
7
  require 'dmarcator/report'
8
8
  require 'dmarcator/version'
9
+
10
+ #
11
+ # Parses, queries, and validates DMARC records and aggregates.
12
+ #
13
+ # Use {DMARCator::Record} for `_dmarc.<domain>` TXT records, {DMARCator::Report}
14
+ # for aggregate/forensic XML reports, and {DMARCator::Resolver} to choose the
15
+ # DNS transport (plaintext, DNS over HTTPS, or DNS over TLS).
16
+ #
17
+ # @see https://www.rfc-editor.org/rfc/rfc7489
18
+ #
19
+ module DMARCator
20
+ 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.5.1
4
+ version: 0.6.1
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