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 +4 -4
- data/ChangeLog.md +15 -0
- data/README.md +66 -0
- data/lib/dmarcator/exceptions.rb +16 -0
- data/lib/dmarcator/parser.rb +42 -0
- data/lib/dmarcator/query.rb +25 -4
- data/lib/dmarcator/record.rb +7 -7
- data/lib/dmarcator/report/auth_result.rb +12 -1
- data/lib/dmarcator/report/metadata.rb +16 -1
- data/lib/dmarcator/report/policy.rb +16 -1
- data/lib/dmarcator/report/reason.rb +6 -1
- data/lib/dmarcator/report/record.rb +18 -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
- data/lib/dmarcator.rb +12 -0
- 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: ab57f0c6599df86ed3331199b84f0abdd9aae8876405e4205f2070b9e457b5ca
|
|
4
|
+
data.tar.gz: 5466098000ff91848f6cb8208606ed8b3312eb9b476763074d917b3f7f377aca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/dmarcator/exceptions.rb
CHANGED
|
@@ -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
|
data/lib/dmarcator/parser.rb
CHANGED
|
@@ -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
|
|
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
|
@@ -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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/dmarcator/version.rb
CHANGED
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.
|
|
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
|