dmarcator 0.3.1 → 0.4.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: 00c852a4815b31a240c4c24071c523ff0af57f6d87b447fbcec7515f8b502e55
4
- data.tar.gz: b325e8eb13e3ee9f91215800492a9d81142338ad71992c998f1d0976756b4214
3
+ metadata.gz: 2c4ed90f1362c3a69141cadeb2e7360b71a7f86c18f74f433990d0760d2bab0d
4
+ data.tar.gz: 63d27b1fc2acbf70627757d7a5604c1cf471e2d2f7dc71c80e8e221f663f3a70
5
5
  SHA512:
6
- metadata.gz: d7b2dd3cbcbd800cfc3b1de240200ec0493fa6d82794282f00aa4c23d209f4269f8335f55468091e4f69dad70206b56c6dcac33c5da578f385507a1bd3eb531a
7
- data.tar.gz: 3faa47a127473ce206dc30d983772f5189d4d7cec302394ab96dcf64afe481463bfdaad645bf99e2c38cd14bd133d1c7c637744646dfed2d62662a5b105578f7
6
+ metadata.gz: 92f2851179be4e2ee8eaaa542afa87ab6efcb2aed546161001778efda5f41579a78a552b1a30010ff625f877f94cbf08c7b7506ffbdacc06fc1d68c9753ef415
7
+ data.tar.gz: 10ad6dc240c07a14705ac06c83388c0981b4476ae03c980323b0d288f69693ea552218b94615524c9c7b8692b521a316157fd0c3fed87f96726acf2dfc7ce4b8
data/ChangeLog.md CHANGED
@@ -1,3 +1,12 @@
1
+ ### 0.4.0 / 2026-10-26
2
+
3
+ * Add `strict: true` parsing mode to reject unknown tags and invalid tag values.
4
+ * Return `nil` for non-numeric report integers (`count`, `pct`) instead of coercing them to `0`.
5
+ * Disable network access (`NONET`) explicitly when parsing XML reports.
6
+ * Reject DNS labels with leading or trailing hyphens.
7
+ * Set explicit DNS query timeouts.
8
+ * Reject XML reports larger than 25 MiB.
9
+
1
10
  ### 0.3.1 / 2026-10-26
2
11
 
3
12
  * Update the gemspec maintainer metadata to list only the fork's author.
data/README.md CHANGED
@@ -65,7 +65,7 @@ record.aspf
65
65
  # => :r
66
66
 
67
67
  record.fo
68
- # => ["0"]
68
+ # => ["1"]
69
69
 
70
70
  record.pct
71
71
  # => 100
@@ -93,6 +93,16 @@ record = DMARCator::Record.query("twitter.com")
93
93
  # => #<DMARCator::Record ... @p=:reject, @rua=[...], ...>
94
94
  ```
95
95
 
96
+ ## Strict parsing
97
+
98
+ By default, unknown tags and tags with invalid values are ignored. Pass
99
+ `strict: true` to reject them instead:
100
+
101
+ ```ruby
102
+ DMARCator::Record.parse("v=DMARC1; p=none; pct=abc", strict: true)
103
+ # => DMARCator::InvalidRecord: invalid DMARC tag value: "pct=abc"
104
+ ```
105
+
96
106
  ## Parsing XML reports
97
107
 
98
108
  Parse a DMARC aggregate/forensic report:
@@ -113,6 +123,18 @@ report.records.first.source_ip
113
123
  # => "209.85.220.69"
114
124
  ```
115
125
 
126
+ ## Security
127
+
128
+ DMARC records and XML reports are treated as untrusted input:
129
+
130
+ * XML reports are parsed with network access disabled (`NONET`), so external
131
+ entities are never resolved or fetched.
132
+ * Reports larger than 25 MiB are rejected before parsing.
133
+ * A warning is emitted at load time if the bundled `resolv` gem is affected by
134
+ CVE-2026-80212/CVE-2026-80213.
135
+ * Domain names are validated before resolution (labels ≤ 63 octets, total ≤
136
+ 248 octets, no leading/trailing hyphens).
137
+
116
138
  ## Requirements
117
139
 
118
140
  * [Ruby] >= 3.3.13
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'parslet'
2
4
 
3
5
  module DMARCator
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'dmarcator/exceptions'
2
4
  require 'dmarcator/uri'
3
5
 
@@ -45,8 +47,14 @@ module DMARCator
45
47
 
46
48
  def self.tag_rule(name,tag,&block)
47
49
  rule(:"dmarc_#{name}") do
48
- str(tag) >> wsp? >> str('=') >> wsp? >>
49
- (instance_eval(&block).as(tag.to_sym) | unknown_value)
50
+ (
51
+ str(tag) >> wsp? >> str('=') >> wsp? >>
52
+ instance_eval(&block).as(tag.to_sym)
53
+ ) |
54
+ (
55
+ str(tag) >> wsp? >> str('=') >> wsp? >>
56
+ match["^; \t"].repeat(1)
57
+ ).as(:invalid_tag)
50
58
  end
51
59
  end
52
60
 
@@ -81,8 +89,7 @@ module DMARCator
81
89
  tag_rule(:adkim, 'adkim') { match['rs'] }
82
90
  tag_rule(:aspf, 'aspf') { match['rs'] }
83
91
 
84
- rule(:unknown_tag) { match["^; \t"].repeat(1) }
85
- rule(:unknown_value) { match["^; \t"].repeat(1) }
92
+ rule(:unknown_tag) { match["^; \t"].repeat(1).as(:unknown_tag) }
86
93
 
87
94
  rule(:dmarc_uri) do
88
95
  mailto_uri.as(:uri) >> (
@@ -93,7 +100,8 @@ module DMARCator
93
100
  end
94
101
 
95
102
  rule(:mailto_uri) do
96
- str('mailto:') >> mailto_address
103
+ match['mM'] >> match['aA'] >> match['iI'] >> match['lL'] >> match['tT'] >> match['oO'] >>
104
+ str(':') >> mailto_address
97
105
  end
98
106
 
99
107
  rule(:mailto_address) do
@@ -176,11 +184,33 @@ module DMARCator
176
184
  # @param [String] record
177
185
  # The raw DMARC record to parse.
178
186
  #
187
+ # @param [Boolean] strict (false)
188
+ # When `true`, raise on unknown tags or invalid tag values instead of
189
+ # silently ignoring them.
190
+ #
179
191
  # @return [Hash{Symbol => Object}]
180
192
  # The Hash of tags within the record.
181
193
  #
182
- def self.parse(record)
183
- tags = Transform.new.apply(new.parse(record))
194
+ # @raise [InvalidRecord]
195
+ # The record is malformed, or (in strict mode) contains an unknown tag
196
+ # or invalid tag value.
197
+ #
198
+ def self.parse(record, strict: false)
199
+ tree = new.parse(record)
200
+
201
+ if strict
202
+ tree.each do |node|
203
+ next unless ignored?(node)
204
+
205
+ if node.key?(:unknown_tag)
206
+ raise InvalidRecord, "unknown DMARC tag: #{Transform.truncate(node[:unknown_tag].to_s).inspect}"
207
+ else
208
+ raise InvalidRecord, "invalid DMARC tag value: #{Transform.truncate(node[:invalid_tag].to_s).inspect}"
209
+ end
210
+ end
211
+ end
212
+
213
+ tags = Transform.new.apply(tree.reject { |node| ignored?(node) })
184
214
  hash = {}
185
215
 
186
216
  tags.each do |tag|
@@ -198,6 +228,19 @@ module DMARCator
198
228
  return hash
199
229
  end
200
230
 
231
+ #
232
+ # Determines whether a parse-tree node represents a tag that the lenient
233
+ # parser dropped because it was unknown or had an invalid value.
234
+ #
235
+ # @param [Object] node
236
+ # The parse-tree node.
237
+ #
238
+ # @return [Boolean]
239
+ #
240
+ def self.ignored?(node)
241
+ node.is_a?(Hash) && (node.key?(:unknown_tag) || node.key?(:invalid_tag))
242
+ end
243
+
201
244
  #
202
245
  # Validates the parsed tags.
203
246
  #
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'resolv'
2
4
  require 'rubygems'
3
5
 
@@ -10,6 +12,11 @@ module DMARCator
10
12
  Gem::Requirement.new('>= 0.4.0', '< 0.7.2')
11
13
  ].freeze
12
14
 
15
+ #
16
+ # DNS query timeouts in seconds, tried in order.
17
+ #
18
+ DNS_TIMEOUTS = [2, 4, 8].freeze
19
+
13
20
  #
14
21
  # Determines the version of the loaded `resolv` gem, if available.
15
22
  #
@@ -56,7 +63,7 @@ module DMARCator
56
63
  !domain.start_with?('.') &&
57
64
  !domain.include?('..') &&
58
65
  !domain.match?(/[^A-Za-z0-9._-]/) &&
59
- domain.split('.').all? { |label| label.length <= 63 }
66
+ domain.split('.').all? { |label| label.length <= 63 && !label.start_with?('-') && !label.end_with?('-') }
60
67
  raise ArgumentError, "invalid domain: #{domain.inspect}"
61
68
  end
62
69
 
@@ -70,7 +77,7 @@ module DMARCator
70
77
  # The domain to query DMARC for.
71
78
  #
72
79
  # @param [Resolv::DNS] resolver
73
- # The resolver to use.
80
+ # The resolver to use. Its timeouts are set to {DNS_TIMEOUTS}.
74
81
  #
75
82
  # @return [String, nil]
76
83
  # The domain's DMARC record or `nil` if none exists.
@@ -82,6 +89,8 @@ module DMARCator
82
89
  def self.query(domain,resolver=Resolv::DNS.new)
83
90
  validate_domain(domain)
84
91
 
92
+ resolver.timeouts = DNS_TIMEOUTS
93
+
85
94
  host = "_dmarc.#{domain}"
86
95
 
87
96
  begin
@@ -1,9 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'dmarcator/query'
2
4
  require 'dmarcator/parser'
3
5
  require 'dmarcator/exceptions'
4
6
 
5
7
  require 'resolv'
6
- require 'uri'
7
8
 
8
9
  module DMARCator
9
10
  class Record
@@ -18,7 +19,7 @@ module DMARCator
18
19
  # @return [Array<Uri>]
19
20
  attr_reader :rua
20
21
 
21
- # `rua` field.
22
+ # `ruf` field.
22
23
  #
23
24
  # @return [Array<Uri>]
24
25
  attr_reader :ruf
@@ -76,7 +77,7 @@ module DMARCator
76
77
  end
77
78
 
78
79
  #
79
- # Determines if the `sp=` field was specified?
80
+ # Determines if the `sp=` field was specified.
80
81
  #
81
82
  # @return [Boolean]
82
83
  #
@@ -160,7 +161,7 @@ module DMARCator
160
161
  end
161
162
 
162
163
  #
163
- # Determines if the `p=` field was specified?
164
+ # Determines if the `p=` field was specified.
164
165
  #
165
166
  # @return [Boolean]
166
167
  #
@@ -234,7 +235,7 @@ module DMARCator
234
235
  end
235
236
 
236
237
  #
237
- # Determines if the `rua=` field was specified?
238
+ # Determines if the `rua=` field was specified.
238
239
  #
239
240
  # @return [Boolean]
240
241
  #
@@ -245,7 +246,7 @@ module DMARCator
245
246
  end
246
247
 
247
248
  #
248
- # Determines if the `ruf=` field was specified?
249
+ # Determines if the `ruf=` field was specified.
249
250
  #
250
251
  # @return [Boolean]
251
252
  #
@@ -261,6 +262,10 @@ module DMARCator
261
262
  # @param [String] record
262
263
  # The raw DMARC record.
263
264
  #
265
+ # @param [Boolean] strict (false)
266
+ # When `true`, raise on unknown tags or invalid tag values instead of
267
+ # silently ignoring them.
268
+ #
264
269
  # @return [Record]
265
270
  # The parsed DMARC record.
266
271
  #
@@ -271,8 +276,8 @@ module DMARCator
271
276
  #
272
277
  # @api public
273
278
  #
274
- def self.parse(record)
275
- attributes = Parser.parse(record)
279
+ def self.parse(record, strict: false)
280
+ attributes = Parser.parse(record, strict: strict)
276
281
 
277
282
  unless attributes[:p]
278
283
  raise InvalidRecord, "DMARC record is missing the required 'p' tag"
@@ -281,8 +286,6 @@ module DMARCator
281
286
  new(attributes)
282
287
  rescue Parslet::ParseFailed => error
283
288
  raise InvalidRecord.new(truncate_error_message(error.message), error.cause)
284
- rescue URI::Error => error
285
- raise InvalidRecord.new(truncate_error_message(error.message), error)
286
289
  end
287
290
 
288
291
  #
@@ -21,7 +21,7 @@ module DMARCator
21
21
  self.public_send("#{attr}=", get_text("feedback/policy_published/#{attr}"))
22
22
  end
23
23
 
24
- self.pct = self.pct && !self.pct.empty? ? self.pct.to_i : nil
24
+ self.pct = get_int('feedback/policy_published/pct')
25
25
  end
26
26
  end
27
27
  end
@@ -14,12 +14,15 @@ module DMARCator
14
14
  #
15
15
  # Represents a parsed DMARC aggregate/forensic XML report.
16
16
  #
17
- # Nokogiri's default parse options are used, which do not resolve external
18
- # entities or fetch network resources; treat report XML as untrusted input.
17
+ # Parsing disables network access (NONET) so external entities are never
18
+ # resolved or fetched; treat report XML as untrusted input.
19
19
  #
20
20
  class Report
21
21
  include XmlHelper
22
22
 
23
+ # Maximum accepted report size in bytes (25 MiB).
24
+ MAX_REPORT_BYTES = 25 * 1024 * 1024
25
+
23
26
  #
24
27
  # Initializes the report from an XML string.
25
28
  #
@@ -28,7 +31,12 @@ module DMARCator
28
31
  #
29
32
  def initialize(xml)
30
33
  @xml = xml
31
- @node = Nokogiri::XML(xml)
34
+
35
+ if xml.is_a?(String) && xml.bytesize > MAX_REPORT_BYTES
36
+ raise InvalidReport, "DMARC XML report exceeds maximum size of #{MAX_REPORT_BYTES} bytes"
37
+ end
38
+
39
+ @node = Nokogiri::XML(xml) { |config| config.nonet }
32
40
 
33
41
  unless @node.errors.empty?
34
42
  raise InvalidReport, "invalid DMARC XML report: #{@node.errors.first}"
data/lib/dmarcator/uri.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DMARCator
2
4
  #
3
5
  # Represents a DMARC URI.
@@ -113,7 +115,7 @@ module DMARCator
113
115
  return str
114
116
  end
115
117
 
116
- protected
118
+ private
117
119
 
118
120
  #
119
121
  # Pass all missing methods to {#uri}.
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DMARCator
2
4
  # dmarcator version
3
- VERSION = '0.3.1'
5
+ VERSION = '0.4.0'
4
6
  end
@@ -48,7 +48,12 @@ module DMARCator
48
48
  #
49
49
  def get_int(xpath)
50
50
  value = get_text(xpath)
51
- value && !value.empty? ? value.to_i : nil
51
+ return nil if value.nil? || value.empty?
52
+
53
+ digits = value.strip
54
+ return nil unless digits.match?(/\A\d+\z/)
55
+
56
+ digits.to_i
52
57
  end
53
58
  end
54
59
  end
data/lib/dmarcator.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'dmarcator/exceptions'
2
4
  require 'dmarcator/query'
3
5
  require 'dmarcator/record'
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.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Berman
@@ -82,15 +82,6 @@ files:
82
82
  - lib/dmarcator/uri.rb
83
83
  - lib/dmarcator/version.rb
84
84
  - lib/dmarcator/xml_helper.rb
85
- - spec/data/alexa.csv
86
- - spec/dmarcator_spec.rb
87
- - spec/parser_spec.rb
88
- - spec/record_spec.rb
89
- - spec/report_spec.rb
90
- - spec/spec_helper.rb
91
- - spec/support/complete.xml
92
- - spec/support/google.xml
93
- - spec/uri_spec.rb
94
85
  homepage: https://github.com/bermannoah/dmarc
95
86
  licenses:
96
87
  - MIT
@@ -115,13 +106,4 @@ requirements: []
115
106
  rubygems_version: 4.0.23
116
107
  specification_version: 4
117
108
  summary: DMARC record parser and query client
118
- test_files:
119
- - spec/data/alexa.csv
120
- - spec/dmarcator_spec.rb
121
- - spec/parser_spec.rb
122
- - spec/record_spec.rb
123
- - spec/report_spec.rb
124
- - spec/spec_helper.rb
125
- - spec/support/complete.xml
126
- - spec/support/google.xml
127
- - spec/uri_spec.rb
109
+ test_files: []