dmarcator 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50fb6579de6a2c481e5154bd28500cdb8c9800ba6897cd6aa76952c985531f22
4
- data.tar.gz: d36d360f6d6cfe6e022cca51af4eec27c03349951c6259b486e178d31d2e32ad
3
+ metadata.gz: dfd0c6f8b9f84d311a49c5ad8a6b0e43618bd151ebd9cb7532a76486f4ea600a
4
+ data.tar.gz: 1b0eb0947c60844e369594d4c28e68510a91246faceb4a099f2c08dd7391a3fa
5
5
  SHA512:
6
- metadata.gz: 8aaea7f0723b6a2eb070345ba516e09b0b6a0391d35c19349eab9791b72ddc06b90f149706c8bf988f3c80784cfe47319577f3c10d190592c482c84320e4962d
7
- data.tar.gz: bafee6506fdd045ff918bcbb89a7553fd761bcbbdd91eb274d737dc02d87b14014a5011566799f570197dbc0ffd71cc37872f3cf915eadeda8022c591e594b97
6
+ metadata.gz: a151f9aabc45f12dc8107a0d4fb8c61bcb4f66880698045ba6df8dc97664f4840db0dc98af62fce3f2c77243f9250ee0df6d886908816a71b853d55f94d4ca8a
7
+ data.tar.gz: 7d7d50f10d95e4d762e8a8f3f1deb9fab301f690b942e65f77129f0ee633afa108281a86947ac6fe81a59e8062fe62f51e9b492ab0f2e08940b6483ad27f8248
data/ChangeLog.md CHANGED
@@ -1,3 +1,21 @@
1
+ ### 0.7.0 / 2026-10-29
2
+
3
+ * Plaintext DNS lookups now fail closed: a lookup failure raises `DMARCator::ResolutionError` instead of being reported as a domain without a DMARC record. Previously a timeout or resolver failure looked like no policy, which failed open. This is a behavior change and applies to every transport.
4
+ * DoH responses must use the `application/dns-message` content type (RFC 8484) and are capped at 65,535 bytes.
5
+ * `DMARCator::Uri` no longer forwards calls to private methods of the wrapped `URI::MailTo`.
6
+ * Remove the unused `XmlHelper#get_int`/`#get_timestamp` helpers and the deprecated `Record.from_txt`.
7
+ * Memoize a missing report version instead of recomputing it, and scope spec constants under `DMARCator`.
8
+ * Add `changelog_uri` and `documentation_uri` gem metadata, and document that domains must be passed as A-labels.
9
+ * Add a CI workflow that runs the specs on the minimum supported Ruby and the current release, loads the gem with warnings enabled, fails on YARD warnings, and builds the gem.
10
+
11
+ ### 0.6.1 / 2026-10-29
12
+
13
+ * 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).
14
+ * Add tests covering `Record#sp` falling back to `p` when `sp` is omitted.
15
+ * Document every report accessor, constant, and public class that YARD previously omitted; documentation coverage is now 100% with no warnings.
16
+ * Note the encrypted-transport guarantees in the README security section.
17
+ * No functional changes.
18
+
1
19
  ### 0.6.0 / 2026-10-28
2
20
 
3
21
  * 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.
data/README.md CHANGED
@@ -93,6 +93,11 @@ record = DMARCator::Record.query("twitter.com")
93
93
  # => #<DMARCator::Record ... @p=:reject, @rua=[...], ...>
94
94
  ```
95
95
 
96
+ Domains must be ASCII (A-labels): pass punycode such as
97
+ `xn--mnchen-3ya.de` rather than `münchen.de`. A lookup failure raises
98
+ `DMARCator::ResolutionError`; `nil` only means the domain genuinely has no
99
+ DMARC record.
100
+
96
101
  ## Strict parsing
97
102
 
98
103
  By default, unknown tags and tags with invalid values are ignored. Pass
@@ -149,9 +154,11 @@ hostname.
149
154
 
150
155
  Encrypted transports fail closed: a transport failure raises
151
156
  `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.
157
+ DMARC record. This applies to every transport, including plaintext —
158
+ `Resolv` returns an empty result for NXDOMAIN and raises for timeouts and
159
+ other failures, so a failed lookup is never mistaken for an absent record.
160
+ Plaintext remains the default because forcing a public resolver breaks private
161
+ and split-horizon zones, and some networks block port 853 or public resolvers.
155
162
 
156
163
  A resolver can also be built explicitly:
157
164
 
@@ -177,6 +184,12 @@ DMARC records and XML reports are treated as untrusted input:
177
184
  CVE-2026-80212/CVE-2026-80213.
178
185
  * Domain names are validated before resolution (labels ≤ 63 octets, total ≤
179
186
  248 octets, no leading/trailing hyphens).
187
+ * Optional [DNS over HTTPS and DNS over TLS](#dns-transport) transports verify
188
+ TLS certificates and fail closed: a transport failure raises
189
+ `DMARCator::ResolutionError` rather than being reported as a domain without a
190
+ DMARC record, so a blocked or downgraded lookup can never silently enforce no
191
+ policy. Plaintext lookups fail closed the same way. DoH responses must be
192
+ `application/dns-message` and are capped at 65,535 bytes.
180
193
 
181
194
  ## Requirements
182
195
 
@@ -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
 
@@ -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
 
@@ -104,11 +104,11 @@ module DMARCator
104
104
  # plaintext resolvers receive {DNS_TIMEOUTS}.
105
105
  #
106
106
  # @return [String, nil]
107
- # The domain's DMARC record or `nil` if none exists.
107
+ # The domain's DMARC record, or `nil` if the domain simply has none.
108
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.
109
+ # @raise [ResolutionError]
110
+ # The lookup failed. A failure is never reported as a missing record, so a
111
+ # blocked or degraded resolver cannot silently enforce no policy.
112
112
  #
113
113
  # @api semipublic
114
114
  #
@@ -121,10 +121,13 @@ module DMARCator
121
121
 
122
122
  host = "_dmarc.#{domain}"
123
123
 
124
+ # Resolv returns an empty result for NXDOMAIN and raises for timeouts and
125
+ # other failures, so any exception here is a transport failure, not an
126
+ # absent record.
124
127
  begin
125
128
  records = resolver.getresources(host, Resolv::DNS::Resource::IN::TXT)
126
- rescue Resolv::ResolvError
127
- return nil
129
+ rescue Resolv::ResolvError => error
130
+ raise ResolutionError, "DNS lookup failed for #{host}: #{error.message}"
128
131
  end
129
132
 
130
133
  # Per RFC 7489 §6.6.3, use the TXT record beginning with "v=DMARC1".
@@ -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]
@@ -288,13 +288,6 @@ module DMARCator
288
288
  raise InvalidRecord.new(truncate_error_message(error.message), error.cause)
289
289
  end
290
290
 
291
- #
292
- # @deprecated use {parse} instead.
293
- #
294
- def self.from_txt(rec)
295
- parse(rec)
296
- end
297
-
298
291
  # Maximum length of error messages echoed from parsing untrusted input.
299
292
  MAX_ERROR_MESSAGE_LENGTH = 500
300
293
 
@@ -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)
@@ -78,7 +78,9 @@ module DMARCator
78
78
  # @return [BigDecimal, nil]
79
79
  #
80
80
  def version
81
- @version ||= begin
81
+ return @version if defined?(@version)
82
+
83
+ @version = begin
82
84
  value = get_text('feedback/version')
83
85
 
84
86
  if value && !value.empty?
@@ -25,6 +25,12 @@ module DMARCator
25
25
  # Default connect/read timeout in seconds.
26
26
  DEFAULT_TIMEOUT = 5
27
27
 
28
+ # Maximum accepted response body in bytes (the DNS message limit).
29
+ MAX_RESPONSE_BYTES = 65_535
30
+
31
+ # Media type RFC 8484 requires for DNS messages.
32
+ CONTENT_TYPE = 'application/dns-message'
33
+
28
34
  # @return [URI::HTTPS] the DoH endpoint.
29
35
  attr_reader :url
30
36
 
@@ -92,8 +98,8 @@ module DMARCator
92
98
  http.ipaddr = @ipaddr if @ipaddr
93
99
 
94
100
  request = Net::HTTP::Post.new(@url.request_uri)
95
- request['content-type'] = 'application/dns-message'
96
- request['accept'] = 'application/dns-message'
101
+ request['content-type'] = CONTENT_TYPE
102
+ request['accept'] = CONTENT_TYPE
97
103
  request.body = query.encode
98
104
 
99
105
  response = http.start { |connection| connection.request(request) }
@@ -104,7 +110,20 @@ module DMARCator
104
110
  raise ResolutionError, "DoH endpoint returned HTTP #{status}"
105
111
  end
106
112
 
107
- response.body.to_s.b
113
+ content_type = response['content-type'].to_s
114
+
115
+ unless content_type.start_with?(CONTENT_TYPE)
116
+ raise ResolutionError, "DoH endpoint returned unexpected content-type: #{content_type.inspect}"
117
+ end
118
+
119
+ body = response.body.to_s.b
120
+
121
+ if body.bytesize > MAX_RESPONSE_BYTES
122
+ raise ResolutionError,
123
+ "DoH response exceeds maximum size of #{MAX_RESPONSE_BYTES} bytes"
124
+ end
125
+
126
+ body
108
127
  rescue ResolutionError
109
128
  raise
110
129
  rescue StandardError => error
data/lib/dmarcator/uri.rb CHANGED
@@ -118,19 +118,24 @@ module DMARCator
118
118
  private
119
119
 
120
120
  #
121
- # Pass all missing methods to {#uri}.
121
+ # Pass public methods through to {#uri}.
122
+ #
123
+ # Private methods are not forwarded: delegation is limited to the wrapped
124
+ # URI's public interface.
122
125
  #
123
126
  def method_missing(name,*arguments,&block)
124
- @uri.send(name,*arguments,&block)
127
+ return super unless @uri.respond_to?(name)
128
+
129
+ @uri.public_send(name,*arguments,&block)
125
130
  end
126
131
 
127
132
  #
128
- # Indicates whether a missing method can be handled by {#uri}.
133
+ # Indicates whether a missing public method can be handled by {#uri}.
129
134
  #
130
135
  # @return [Boolean]
131
136
  #
132
137
  def respond_to_missing?(name, include_private = false)
133
- @uri.respond_to?(name, include_private) || super
138
+ @uri.respond_to?(name) || super
134
139
  end
135
140
 
136
141
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DMARCator
4
4
  # dmarcator version
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.0'
6
6
  end
@@ -20,30 +20,6 @@ module DMARCator
20
20
  @node.at_xpath(xpath)&.text
21
21
  end
22
22
 
23
- #
24
- # Returns a Unix timestamp as a `Time`.
25
- #
26
- # @param [String] xpath
27
- # The XPath expression to match.
28
- #
29
- # @return [Time, nil]
30
- #
31
- def get_timestamp(xpath)
32
- parse_timestamp(get_text(xpath))
33
- end
34
-
35
- #
36
- # Returns an integer value.
37
- #
38
- # @param [String] xpath
39
- # The XPath expression to match.
40
- #
41
- # @return [Integer, nil]
42
- #
43
- def get_int(xpath)
44
- parse_integer(get_text(xpath))
45
- end
46
-
47
23
  #
48
24
  # Parses a Unix timestamp from raw element text.
49
25
  #
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman
@@ -92,6 +92,8 @@ licenses:
92
92
  metadata:
93
93
  source_code_uri: https://github.com/bermannoah/dmarcator
94
94
  bug_tracker_uri: https://github.com/bermannoah/dmarcator/issues
95
+ changelog_uri: https://github.com/bermannoah/dmarcator/blob/main/ChangeLog.md
96
+ documentation_uri: https://rubydoc.info/gems/dmarcator
95
97
  rubygems_mfa_required: 'true'
96
98
  rdoc_options: []
97
99
  require_paths: