dmarcator 0.1.0 → 0.3.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: e13144ffc39b0f47db1c26f65201c05db51af4701cf51ffd2398dcab8011a791
4
- data.tar.gz: 7560c54eb55c97de2a841e22f5c1ff4469741b23a2bd3f593934eb24b21c3582
3
+ metadata.gz: '03348cb4b245b8e35dde8ba708559df27d8fd40a6e52d1e15bd73db8732e49d8'
4
+ data.tar.gz: fac2e24464f16d080f27e7fd1fede61720947dffda32a792d5b5b20d4f507373
5
5
  SHA512:
6
- metadata.gz: f7966f69b4b9741fad6a84c6f502631137f32891955d81a3f94e3b83fe01810fca1a8a94544dbb4f81f6adbc361b6d6d89374377d435e46f8df44a82e247a37a
7
- data.tar.gz: 016b57445827a1c297870e079a148be4c0ce786dabc3f0cb2574a09beb00c8d91742a117b0d03e8095c3b4f4aeeb46a22a88431c3c4b781df948021a77f1bab4
6
+ metadata.gz: 2c5c33baf620a3520d927f4eb1f7b3078703201f3c314779fd42aef5f97d844280a5d25e82103f2a6e6aae963779a9f8f25962ea92758b44e4a4054844363c50
7
+ data.tar.gz: bc9e4584af2e2a158c918f855e80e76d590f539d69d3571b1edcac12a036bca01ee269cc88c3f3d63f670b65c753dc068cce3d1f9e7d7a4a38b0783a41a7ad2d
data/ChangeLog.md CHANGED
@@ -1,3 +1,18 @@
1
+ ### 0.3.0 / 2026-10-26
2
+
3
+ * Raise `DMARCator::InvalidReport` for malformed XML reports instead of returning an empty report.
4
+ * Return `nil` for a non-numeric report `version` instead of raising `ArgumentError`.
5
+ * Return `nil` for a non-numeric report timestamp instead of coercing it to the Unix epoch.
6
+ * Add `DMARCator::Uri#eql?` and `#hash` for correct Hash/Set semantics.
7
+ * Harden `DMARCator.validate_domain` against CVE-2026-80213 (63-octet label limit and 248-octet total limit).
8
+ * Warn at load time when the `resolv` gem is affected by CVE-2026-80212/CVE-2026-80213.
9
+ * Require Ruby >= 3.3.13 to pick up the Regexp compilation fixes behind GitLab's CVE-2026-89078, CVE-2026-93577, CVE-2026-97229 and CVE-2026-110264.
10
+
11
+ ### 0.2.0 / 2026-10-13
12
+
13
+ * Added DMARC XML report parsing, ported from the `dmarc_parser` gem by D Salko.
14
+ * Added `nokogiri` and `bigdecimal` runtime dependencies.
15
+
1
16
  ### 0.1.0 / 2026-10-13
2
17
 
3
18
  * Forked from the Trail of Bits `dmarc` gem and published as `dmarcator`.
data/LICENSE.txt CHANGED
@@ -1,6 +1,7 @@
1
1
  The MIT License (MIT)
2
2
 
3
3
  Copyright (c) 2014-2016 Trail of Bits
4
+ Copyright (c) 2020 D Salko
4
5
  Copyright (c) 2026 Noah Berman
5
6
 
6
7
  Permission is hereby granted, free of charge, to any person obtaining a copy of
data/README.md CHANGED
@@ -11,8 +11,9 @@ DMARCator is a Ruby library for parsing and querying [DMARC] records.
11
11
  > a stricter parser. See [Acknowledgments](#acknowledgments) below.
12
12
 
13
13
  [DMARC] policies are published as DNS TXT records at `_dmarc.<domain>`. This
14
- library parses those records into a structured `DMARCator::Record` and can query
15
- them directly through Ruby's `Resolv` DNS client.
14
+ library parses those records into a structured `DMARCator::Record`, queries them
15
+ directly through Ruby's `Resolv` DNS client, and parses the XML aggregate
16
+ reports those policies generate.
16
17
 
17
18
  ## Installation
18
19
 
@@ -92,10 +93,32 @@ record = DMARCator::Record.query("twitter.com")
92
93
  # => #<DMARCator::Record ... @p=:reject, @rua=[...], ...>
93
94
  ```
94
95
 
96
+ ## Parsing XML reports
97
+
98
+ Parse a DMARC aggregate/forensic report:
99
+
100
+ ```ruby
101
+ report = DMARCator::Report.new(xml_string)
102
+
103
+ report.version
104
+ # => 0.1e0
105
+
106
+ report.metadata.org_name
107
+ # => "google.com"
108
+
109
+ report.policy.domain
110
+ # => "opserv.io"
111
+
112
+ report.records.first.source_ip
113
+ # => "209.85.220.69"
114
+ ```
115
+
95
116
  ## Requirements
96
117
 
97
- * [Ruby] >= 3.0.0
118
+ * [Ruby] >= 3.3.13
98
119
  * [parslet] >= 1.0.0, < 3.0.0
120
+ * [nokogiri] >= 1.10
121
+ * [bigdecimal] >= 1.0
99
122
 
100
123
  ## Testing
101
124
 
@@ -129,9 +152,15 @@ This fork renames the `DMARC` module to `DMARCator` (loaded via
129
152
  * a DNS query path that selects the `v=DMARC1` TXT record per RFC 7489,
130
153
  * hardened packaging and test fixtures.
131
154
 
155
+ The DMARC XML report parser is ported from [`dmarc_parser`][dmarc-parser-gem],
156
+ created by D Salko and released under the MIT License.
157
+
132
158
  [DMARC]: https://www.rfc-editor.org/rfc/rfc7489
133
159
  [dmarc-gem]: https://rubygems.org/gems/dmarc
160
+ [dmarc-parser-gem]: https://rubygems.org/gems/dmarc_parser
134
161
  [Trail of Bits]: https://www.trailofbits.com
135
162
  [Ruby]: https://www.ruby-lang.org
136
163
  [parslet]: https://kschiess.github.io/parslet/
164
+ [nokogiri]: https://nokogiri.org
165
+ [bigdecimal]: https://github.com/ruby/bigdecimal
137
166
  [LICENSE.txt]: LICENSE.txt
@@ -3,4 +3,10 @@ require 'parslet'
3
3
  module DMARCator
4
4
  class InvalidRecord < Parslet::ParseFailed
5
5
  end
6
+
7
+ #
8
+ # Raised when a DMARC XML report cannot be parsed.
9
+ #
10
+ class InvalidReport < StandardError
11
+ end
6
12
  end
@@ -1,6 +1,43 @@
1
1
  require 'resolv'
2
+ require 'rubygems'
2
3
 
3
4
  module DMARCator
5
+ #
6
+ # Resolv gem versions affected by CVE-2026-80212 and CVE-2026-80213.
7
+ #
8
+ VULNERABLE_RESOLV = [
9
+ Gem::Requirement.new('< 0.3.2'),
10
+ Gem::Requirement.new('>= 0.4.0', '< 0.7.2')
11
+ ].freeze
12
+
13
+ #
14
+ # Determines the version of the loaded `resolv` gem, if available.
15
+ #
16
+ # @return [String, nil]
17
+ #
18
+ def self.resolv_version
19
+ if defined?(Resolv::VERSION)
20
+ Resolv::VERSION
21
+ elsif (spec = Gem.loaded_specs['resolv'])
22
+ spec.version.to_s
23
+ end
24
+ end
25
+
26
+ #
27
+ # Determines whether the loaded `resolv` gem is affected by
28
+ # CVE-2026-80212 or CVE-2026-80213.
29
+ #
30
+ # @return [Boolean]
31
+ #
32
+ def self.vulnerable_resolv?
33
+ version = resolv_version
34
+ return false unless version
35
+
36
+ VULNERABLE_RESOLV.any? do |requirement|
37
+ requirement.satisfied_by?(Gem::Version.new(version))
38
+ end
39
+ end
40
+
4
41
  #
5
42
  # Validates a domain before using it in a DNS query.
6
43
  #
@@ -15,10 +52,11 @@ module DMARCator
15
52
  #
16
53
  def self.validate_domain(domain)
17
54
  unless domain.is_a?(String) && !domain.empty? &&
18
- domain.length <= 253 &&
55
+ domain.length <= 248 &&
19
56
  !domain.start_with?('.') &&
20
57
  !domain.include?('..') &&
21
- !domain.match?(/[^A-Za-z0-9._-]/)
58
+ !domain.match?(/[^A-Za-z0-9._-]/) &&
59
+ domain.split('.').all? { |label| label.length <= 63 }
22
60
  raise ArgumentError, "invalid domain: #{domain.inspect}"
23
61
  end
24
62
 
@@ -61,3 +99,9 @@ module DMARCator
61
99
  nil
62
100
  end
63
101
  end
102
+
103
+ if DMARCator.vulnerable_resolv?
104
+ warn "dmarcator: resolv #{DMARCator.resolv_version} is affected by " \
105
+ "CVE-2026-80212 and CVE-2026-80213; upgrade to resolv >= 0.7.2 " \
106
+ "(or >= 0.3.2 on Ruby 3.3)"
107
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../xml_helper'
4
+
5
+ module DMARCator
6
+ class Report
7
+ #
8
+ # Represents an `auth_results` entry (DKIM/SPF) in a DMARC XML report.
9
+ #
10
+ class AuthResult
11
+ include XmlHelper
12
+
13
+ ATTRIBUTES = [:type, :domain, :result, :selector, :scope]
14
+
15
+ attr_accessor(*ATTRIBUTES)
16
+
17
+ def initialize(node)
18
+ @node = node
19
+ self.type = node.name
20
+ self.domain = get_text('domain')
21
+ self.result = get_text('result')
22
+ self.selector = get_text('selector')
23
+ self.scope = get_text('scope')
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../xml_helper'
4
+
5
+ module DMARCator
6
+ class Report
7
+ #
8
+ # Represents the `report_metadata` element of a DMARC XML report.
9
+ #
10
+ class Metadata
11
+ include XmlHelper
12
+
13
+ ATTRIBUTES = [
14
+ :org_name, :email, :extra_contact_info, :report_id,
15
+ :begin_at, :end_at, :errors
16
+ ]
17
+
18
+ attr_accessor(*ATTRIBUTES)
19
+
20
+ def initialize(node)
21
+ @node = node
22
+ self.org_name = get_text('feedback/report_metadata/org_name')
23
+ self.email = get_text('feedback/report_metadata/email')
24
+ self.extra_contact_info = get_text('feedback/report_metadata/extra_contact_info')
25
+ self.report_id = get_text('feedback/report_metadata/report_id')
26
+ self.begin_at = get_timestamp('feedback/report_metadata/date_range/begin')
27
+ self.end_at = get_timestamp('feedback/report_metadata/date_range/end')
28
+ self.errors = @node.xpath('feedback/report_metadata/error').map(&:text)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../xml_helper'
4
+
5
+ module DMARCator
6
+ class Report
7
+ #
8
+ # Represents the `policy_published` element of a DMARC XML report.
9
+ #
10
+ class Policy
11
+ include XmlHelper
12
+
13
+ ATTRIBUTES = [:domain, :adkim, :aspf, :p, :sp, :pct, :fo]
14
+
15
+ attr_accessor(*ATTRIBUTES)
16
+
17
+ def initialize(node)
18
+ @node = node
19
+
20
+ ATTRIBUTES.each do |attr|
21
+ self.public_send("#{attr}=", get_text("feedback/policy_published/#{attr}"))
22
+ end
23
+
24
+ self.pct = self.pct && !self.pct.empty? ? self.pct.to_i : nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../xml_helper'
4
+
5
+ module DMARCator
6
+ class Report
7
+ #
8
+ # Represents a `reason` element of a DMARC XML report record.
9
+ #
10
+ class Reason
11
+ include XmlHelper
12
+
13
+ ATTRIBUTES = [:type, :comment]
14
+
15
+ attr_accessor(*ATTRIBUTES)
16
+
17
+ def initialize(node)
18
+ @node = node
19
+ self.type = get_text('type')
20
+ self.comment = get_text('comment')
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../xml_helper'
4
+ require_relative 'auth_result'
5
+ require_relative 'reason'
6
+
7
+ module DMARCator
8
+ class Report
9
+ #
10
+ # Represents a single `record` element of a DMARC XML report.
11
+ #
12
+ class Record
13
+ include XmlHelper
14
+
15
+ ATTRIBUTES = [
16
+ :source_ip, :count, :disposition, :dkim, :spf,
17
+ :envelope_to, :envelope_from, :header_from,
18
+ :auth_results, :reasons
19
+ ]
20
+
21
+ attr_accessor(*ATTRIBUTES)
22
+
23
+ def initialize(node)
24
+ @node = node
25
+ self.source_ip = get_text('row/source_ip')
26
+ self.count = get_int('row/count')
27
+ self.disposition = get_text('row/policy_evaluated/disposition')
28
+ self.dkim = get_text('row/policy_evaluated/dkim')
29
+ self.spf = get_text('row/policy_evaluated/spf')
30
+ self.reasons = node.xpath('row/policy_evaluated/reason').map do |reason_node|
31
+ Reason.new(reason_node)
32
+ end
33
+ self.envelope_to = get_text('identifiers/envelope_to')
34
+ self.envelope_from = get_text('identifiers/envelope_from')
35
+ self.header_from = get_text('identifiers/header_from')
36
+ self.auth_results = node.xpath('auth_results/*').map do |auth_results_node|
37
+ AuthResult.new(auth_results_node)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'bigdecimal'
5
+ require 'bigdecimal/util'
6
+
7
+ require_relative 'xml_helper'
8
+ require_relative 'exceptions'
9
+ require_relative 'report/metadata'
10
+ require_relative 'report/policy'
11
+ require_relative 'report/record'
12
+
13
+ module DMARCator
14
+ #
15
+ # Represents a parsed DMARC aggregate/forensic XML report.
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.
19
+ #
20
+ class Report
21
+ include XmlHelper
22
+
23
+ #
24
+ # Initializes the report from an XML string.
25
+ #
26
+ # @param [String] xml
27
+ # The raw DMARC XML report.
28
+ #
29
+ def initialize(xml)
30
+ @xml = xml
31
+ @node = Nokogiri::XML(xml)
32
+
33
+ unless @node.errors.empty?
34
+ raise InvalidReport, "invalid DMARC XML report: #{@node.errors.first}"
35
+ end
36
+
37
+ @node.remove_namespaces!
38
+ end
39
+
40
+ #
41
+ # The report version.
42
+ #
43
+ # @return [BigDecimal, nil]
44
+ #
45
+ def version
46
+ @version ||= begin
47
+ value = get_text('feedback/version')
48
+
49
+ if value && !value.empty?
50
+ begin
51
+ BigDecimal(value)
52
+ rescue ArgumentError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ #
60
+ # The report metadata.
61
+ #
62
+ # @return [Report::Metadata]
63
+ #
64
+ def metadata
65
+ @metadata ||= Metadata.new(@node)
66
+ end
67
+
68
+ #
69
+ # The published DMARC policy.
70
+ #
71
+ # @return [Report::Policy]
72
+ #
73
+ def policy
74
+ @policy ||= Policy.new(@node)
75
+ end
76
+
77
+ #
78
+ # The report records.
79
+ #
80
+ # @return [Array<Report::Record>]
81
+ #
82
+ def records
83
+ @records ||= @node.xpath('feedback/record').map do |node|
84
+ Record.new(node)
85
+ end
86
+ end
87
+ end
88
+ end
data/lib/dmarcator/uri.rb CHANGED
@@ -75,6 +75,27 @@ module DMARCator
75
75
  (@unit == other.unit)
76
76
  end
77
77
 
78
+ #
79
+ # Determines if the DMARC URI is equivalent to the other.
80
+ #
81
+ # @param [Object] other
82
+ # the other DMARC URI to compare against.
83
+ #
84
+ # @return [Boolean]
85
+ #
86
+ def eql?(other)
87
+ self == other
88
+ end
89
+
90
+ #
91
+ # Computes a hash value consistent with {#eql?}.
92
+ #
93
+ # @return [Integer]
94
+ #
95
+ def hash
96
+ [self.class, @uri, @size, @unit].hash
97
+ end
98
+
78
99
  #
79
100
  # Converts the DMARC URI back into a String.
80
101
  #
@@ -1,4 +1,4 @@
1
1
  module DMARCator
2
2
  # dmarcator version
3
- VERSION = '0.1.0'
3
+ VERSION = '0.3.0'
4
4
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module DMARCator
6
+ #
7
+ # Internal helper for extracting values from parsed DMARC XML reports.
8
+ #
9
+ module XmlHelper
10
+ #
11
+ # Returns the text of the first node matching the XPath expression.
12
+ #
13
+ # @param [String] xpath
14
+ # The XPath expression to match.
15
+ #
16
+ # @return [String, nil]
17
+ # The node text, or `nil` if no node matched.
18
+ #
19
+ def get_text(xpath)
20
+ @node.at_xpath(xpath)&.text
21
+ end
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
+ value = get_text(xpath)
33
+ return nil if value.nil? || value.empty?
34
+
35
+ digits = value.strip
36
+ return nil unless digits.match?(/\A\d+\z/)
37
+
38
+ Time.at(digits.to_i)
39
+ end
40
+
41
+ #
42
+ # Returns an integer value.
43
+ #
44
+ # @param [String] xpath
45
+ # The XPath expression to match.
46
+ #
47
+ # @return [Integer, nil]
48
+ #
49
+ def get_int(xpath)
50
+ value = get_text(xpath)
51
+ value && !value.empty? ? value.to_i : nil
52
+ end
53
+ end
54
+ end
data/lib/dmarcator.rb CHANGED
@@ -2,4 +2,5 @@ require 'dmarcator/exceptions'
2
2
  require 'dmarcator/query'
3
3
  require 'dmarcator/record'
4
4
  require 'dmarcator/parser'
5
+ require 'dmarcator/report'
5
6
  require 'dmarcator/version'
@@ -59,4 +59,70 @@ describe DMARCator do
59
59
  expect { subject.query("foo bar") }.to raise_error(ArgumentError)
60
60
  end
61
61
  end
62
+
63
+ describe ".validate_domain" do
64
+ it "accepts a valid domain" do
65
+ expect(subject.validate_domain("example.com")).to eq("example.com")
66
+ end
67
+
68
+ it "rejects a label longer than 63 octets" do
69
+ expect {
70
+ subject.validate_domain("#{'a' * 64}.com")
71
+ }.to raise_error(ArgumentError)
72
+ end
73
+
74
+ it "accepts a 63-octet label" do
75
+ domain = "#{'a' * 63}.com"
76
+
77
+ expect(subject.validate_domain(domain)).to eq(domain)
78
+ end
79
+
80
+ it "rejects a domain longer than 248 octets" do
81
+ domain = "#{'a' * 62}.#{'a' * 62}.#{'a' * 62}.#{'a' * 60}"
82
+
83
+ expect { subject.validate_domain(domain) }.to raise_error(ArgumentError)
84
+ end
85
+
86
+ it "accepts a domain at the 248-octet limit" do
87
+ domain = "#{'a' * 62}.#{'a' * 62}.#{'a' * 62}.#{'a' * 59}"
88
+
89
+ expect(subject.validate_domain(domain)).to eq(domain)
90
+ end
91
+
92
+ it "rejects a non-string domain" do
93
+ expect { subject.validate_domain(123) }.to raise_error(ArgumentError)
94
+ end
95
+ end
96
+
97
+ describe ".vulnerable_resolv?" do
98
+ it "is true for resolv 0.3.1 and earlier" do
99
+ allow(subject).to receive(:resolv_version).and_return("0.3.1")
100
+
101
+ expect(subject.vulnerable_resolv?).to be(true)
102
+ end
103
+
104
+ it "is false for resolv 0.3.2" do
105
+ allow(subject).to receive(:resolv_version).and_return("0.3.2")
106
+
107
+ expect(subject.vulnerable_resolv?).to be(false)
108
+ end
109
+
110
+ it "is true for resolv 0.4.0 through 0.7.1" do
111
+ allow(subject).to receive(:resolv_version).and_return("0.7.1")
112
+
113
+ expect(subject.vulnerable_resolv?).to be(true)
114
+ end
115
+
116
+ it "is false for resolv 0.7.2 and later" do
117
+ allow(subject).to receive(:resolv_version).and_return("0.7.2")
118
+
119
+ expect(subject.vulnerable_resolv?).to be(false)
120
+ end
121
+
122
+ it "is false when the version is unknown" do
123
+ allow(subject).to receive(:resolv_version).and_return(nil)
124
+
125
+ expect(subject.vulnerable_resolv?).to be(false)
126
+ end
127
+ end
62
128
  end