spf-query 0.0.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.
@@ -0,0 +1,281 @@
1
+ require 'spf/query/ip'
2
+ require 'spf/query/macro'
3
+ require 'spf/query/macro_string'
4
+ require 'spf/query/modifier'
5
+ require 'spf/query/mechanism'
6
+ require 'spf/query/record'
7
+
8
+ require 'parslet'
9
+
10
+ module SPF
11
+ module Query
12
+ #
13
+ # SPF parser.
14
+ #
15
+ # @see https://tools.ietf.org/html/rfc7208#section-7.1
16
+ #
17
+ class Parser < Parslet::Parser
18
+
19
+ root :record
20
+ rule(:record) { version >> sp.repeat(1) >> terms.as(:rules) >> sp.repeat(0) }
21
+ rule(:version) { str('v=') >> str('spf1').as(:version) }
22
+ rule(:terms) { term >> (sp.repeat(1) >> term).repeat(0) }
23
+ rule(:term) { directive | modifier }
24
+ rule(:directive) { (qualifier.maybe >> mechanism).as(:directive) }
25
+ rule(:qualifier) { match['+\-~?'].as(:qualifier) }
26
+
27
+ rule(:mechanism) do
28
+ all |
29
+ include |
30
+ a |
31
+ mx |
32
+ ptr |
33
+ ip4 |
34
+ ip6 |
35
+ exists
36
+ end
37
+
38
+ rule(:all) { str('all').as(:name) }
39
+
40
+ #
41
+ # Section 5.2:
42
+ #
43
+ # include = "include" ":" domain-spec
44
+ #
45
+ rule(:include) do
46
+ str('include').as(:name) >> str(':') >> domain_spec.as(:value)
47
+ end
48
+
49
+ #
50
+ # Section 5.3:
51
+ #
52
+ # A = "a" [ ":" domain-spec ] [ dual-cidr-length ]
53
+ #
54
+ rule(:a) do
55
+ str('a').as(:name) >>
56
+ ((str(':') >> domain_spec).maybe >> dual_cidr_length.maybe).as(:value)
57
+ end
58
+
59
+ #
60
+ # Section 5.4:
61
+ #
62
+ # MX = "mx" [ ":" domain-spec ] [ dual-cidr-length ]
63
+ #
64
+ rule(:mx) do
65
+ str('mx').as(:name) >>
66
+ (
67
+ (str(':') >> domain_spec).maybe >>
68
+ dual_cidr_length.maybe
69
+ ).as(:value)
70
+ end
71
+
72
+ #
73
+ # Section 5.5:
74
+ #
75
+ # PTR = "ptr" [ ":" domain-spec ]
76
+ #
77
+ rule(:ptr) do
78
+ str('ptr').as(:name) >> (str(':') >> domain_spec.as(:value)).maybe
79
+ end
80
+
81
+ #
82
+ # Section 5.6:
83
+ #
84
+ # IP4 = "ip4" ":" ip4-network [ ip4-cidr-length ]
85
+ #
86
+ rule(:ip4) do
87
+ str('ip4').as(:name) >> str(':') >> (ipv4_address >> ipv4_cidr_length.maybe).as(:value)
88
+ end
89
+
90
+ #
91
+ # Section 5.6:
92
+ #
93
+ # IP6 = "ip6" ":" ip6-network [ ip6-cidr-length ]
94
+ #
95
+ rule(:ip6) do
96
+ str('ip6').as(:name) >> str(':') >> (ipv6_address >> ipv6_cidr_length.maybe).as(:value)
97
+ end
98
+
99
+ rule(:dual_cidr_length) do
100
+ ipv4_cidr_length.maybe >> (str('/') >> ipv6_cidr_length).maybe
101
+ end
102
+ rule(:ipv4_cidr_length) { str('/') >> digit.repeat(1).as(:cidr_length) }
103
+ rule(:ipv6_cidr_length) { str('/') >> digit.repeat(1).as(:cidr_length) }
104
+
105
+ #
106
+ # Section 5.7:
107
+ #
108
+ # exists = "exists" ":" domain-spec
109
+ #
110
+ rule(:exists) do
111
+ str('exists').as(:name) >> str(':') >> domain_spec.as(:value)
112
+ end
113
+
114
+ rule(:modifier) do
115
+ (redirect | explanation).as(:modifier) |
116
+ unknown_modifier.as(:unknown_modifier)
117
+ end
118
+ rule(:redirect) do
119
+ str('redirect').as(:name) >> str('=') >> domain_spec.as(:value)
120
+ end
121
+ rule(:explanation) do
122
+ str('exp').as(:name) >> str('=') >> domain_spec.as(:value)
123
+ end
124
+ rule(:unknown_modifier) do
125
+ name.as(:name) >> equals >> macro_string?.as(:value)
126
+ end
127
+
128
+ rule(:domain_spec) { macro_string }
129
+ rule(:name) { alpha >> (alpha | digit | match['-_\.'] ).repeat(0) }
130
+
131
+ #
132
+ # Macro rules
133
+ #
134
+ # See RFC 4408, Section 8.1.
135
+ #
136
+ rule(:macro_string) do
137
+ (
138
+ (macro_expand | macro_literal.repeat(1).as(:literal)).repeat(1)
139
+ ).as(:macro_string)
140
+ end
141
+ rule(:macro_string?) { macro_string.maybe }
142
+ rule(:macro_expand) do
143
+ (
144
+ (
145
+ str('%{') >>
146
+ macro_letter.as(:letter) >>
147
+ transformers >>
148
+ delimiter.repeat(1).as(:delimiters).maybe >>
149
+ str('}')
150
+ ) | str('%%') | str('%_') | str('%-')
151
+ ).as(:macro)
152
+ end
153
+ rule(:macro_literal) { match['\x21-\x24'] | match['\x26-\x7e'] }
154
+ rule(:macro_letter) { match['slodiphcrt'] }
155
+ rule(:transformers) do
156
+ digit.repeat(1).as(:digits).maybe >> str('r').as(:reverse).maybe
157
+ end
158
+ rule(:delimiter) { match['-\.+,/_='].as(:char) }
159
+
160
+ #
161
+ # IP rules:
162
+ #
163
+ # See https://github.com/kschiess/parslet/blob/master/example/ip_address.rb
164
+ #
165
+ rule(:ipv4_address) do
166
+ (
167
+ dec_octet >> str('.') >>
168
+ dec_octet >> str('.') >>
169
+ dec_octet >> str('.') >>
170
+ dec_octet
171
+ ).as(:ip)
172
+ end
173
+
174
+ rule(:dec_octet) do
175
+ str('25') >> match("[0-5]") |
176
+ str('2') >> match("[0-4]") >> digit |
177
+ str('1') >> digit >> digit |
178
+ match('[1-9]') >> digit |
179
+ digit
180
+ end
181
+
182
+ rule(:ipv6_address) do
183
+ (
184
+ (
185
+ h16r(6) |
186
+ dcolon >> h16r(5) |
187
+ h16.maybe >> dcolon >> h16r(4) |
188
+ (h16 >> h16l(1)).maybe >> dcolon >> h16r(3) |
189
+ (h16 >> h16l(2)).maybe >> dcolon >> h16r(2) |
190
+ (h16 >> h16l(3)).maybe >> dcolon >> h16r(1) |
191
+ (h16 >> h16l(4)).maybe >> dcolon
192
+ ) >> ls32 |
193
+
194
+ ((h16 >> h16l(5)).maybe >> dcolon >> h16) |
195
+ ((h16 >> h16l(6)).maybe >> dcolon)
196
+ ).as(:ip)
197
+ end
198
+
199
+ rule(:h16) { hexdigit.repeat(1,4) }
200
+ rule(:ls32) { (h16 >> colon >> h16) | ipv4_address }
201
+
202
+ rule(:sp) { str(' ') }
203
+ rule(:colon) { str(':') }
204
+ rule(:dcolon) { str('::') }
205
+ rule(:slash) { str('/') }
206
+ rule(:equals) { str('=') }
207
+ rule(:alpha) { match['a-zA-Z'] }
208
+ rule(:alphanum) { match['a-zA-Z0-9'] }
209
+ rule(:digit) { match['0-9'] }
210
+ rule(:hexdigit) { match['0-9a-fA-F'] }
211
+
212
+ def h16r(times)
213
+ (h16 >> colon).repeat(times, times)
214
+ end
215
+
216
+ def h16l(times)
217
+ (colon >> h16).repeat(0,times)
218
+ end
219
+
220
+ class Transform < Parslet::Transform
221
+
222
+ rule(ip: simple(:address)) { IP.new(address) }
223
+ rule(ip: simple(:address), cidr_length: simple(:cidr_length)) do
224
+ IP.new(address,cidr_length.to_i)
225
+ end
226
+
227
+ rule(char: simple(:c)) { c }
228
+ rule(literal: simple(:text)) { text }
229
+ rule(macro: subtree(:options)) do
230
+ letter = options.fetch(:letter).to_sym
231
+
232
+ Macro.new(letter,options)
233
+ end
234
+
235
+ rule(macro_string: [simple(:text)]) { text }
236
+ rule(macro_string: sequence(:elements)) { MacroString.new(elements) }
237
+
238
+ rule(modifier: {name: simple(:name)}) do
239
+ Modifier.new(name.to_sym)
240
+ end
241
+
242
+ rule(modifier: {name: simple(:name), value: subtree(:value)}) do
243
+ Modifier.new(name.to_sym,value)
244
+ end
245
+
246
+ rule(unknown_modifier: {name: simple(:name), value: simple(:value)}) do
247
+ UnknownModifier.new(name,value)
248
+ end
249
+
250
+ rule(directive: subtree(:options)) do
251
+ name = options.delete(:name).to_sym
252
+ value = options[:value]
253
+ qualifier = if options[:qualifier]
254
+ Mechanism::QUALIFIERS.fetch(options[:qualifier].to_s)
255
+ end
256
+
257
+ Mechanism.new(name, value: value, qualifier: qualifier)
258
+ end
259
+
260
+ rule(version: simple(:version), rules: subtree(:rules)) do
261
+ Record.new(version.to_sym, Array(rules))
262
+ end
263
+
264
+ end
265
+
266
+ #
267
+ # Parses the SPF record.
268
+ #
269
+ # @param [String] spf
270
+ # The raw SPF record.
271
+ #
272
+ # @return [Record]
273
+ # The parsed SPF record.
274
+ #
275
+ def self.parse(spf)
276
+ Transform.new.apply(new.parse(spf))
277
+ end
278
+
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,48 @@
1
+ require 'resolv'
2
+ require 'resolv/dns/resource/in/spf'
3
+
4
+ module SPF
5
+ module Query
6
+ #
7
+ # Queries the domain for it's SPF record.
8
+ #
9
+ # @param [String] domain
10
+ # The domain to query.
11
+ #
12
+ # @param [Resolv::DNS] resolver
13
+ # The optional resolver to use.
14
+ #
15
+ # @return [String, nil]
16
+ # The SPF record or `nil` if there is none.
17
+ #
18
+ # @api semipublic
19
+ #
20
+ def self.query(domain,resolver=Resolv::DNS.new)
21
+ # check for an SPF record on the domain
22
+ begin
23
+ record = resolver.getresource(domain, Resolv::DNS::Resource::IN::SPF)
24
+
25
+ return record.strings.join
26
+ rescue Resolv::ResolvError
27
+ end
28
+
29
+ # check for SPF in the TXT records
30
+ ["_spf.#{domain}", domain].each do |host|
31
+ begin
32
+ records = resolver.getresources(host, Resolv::DNS::Resource::IN::TXT)
33
+
34
+ records.each do |record|
35
+ txt = record.strings.join
36
+
37
+ if txt.include?('v=spf1')
38
+ return txt
39
+ end
40
+ end
41
+ rescue Resolv::ResolvError
42
+ end
43
+ end
44
+
45
+ return nil
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,204 @@
1
+ require 'spf/query/exceptions'
2
+ require 'spf/query/parser'
3
+ require 'spf/query/query'
4
+
5
+ require 'resolv'
6
+
7
+ module SPF
8
+ module Query
9
+ class Record
10
+
11
+ include Enumerable
12
+
13
+ # The SPF version of the record.
14
+ #
15
+ # @return [:spf1]
16
+ attr_reader :version
17
+ alias v version
18
+
19
+ # The SPF rules.
20
+ #
21
+ # @return [Array<Mechanism, Modifier>]
22
+ attr_reader :rules
23
+
24
+ # All mechanisms within the record.
25
+ #
26
+ # @return [Array<Mechanism>]
27
+ attr_reader :mechanisms
28
+
29
+ # All modifiers within the record.
30
+ #
31
+ # @return [Array<Modifier>]
32
+ attr_reader :modifiers
33
+
34
+ # The right-most `all:` mechanism.
35
+ #
36
+ # @return [Mechanism, nil]
37
+ attr_reader :all
38
+
39
+ # Selects all `include:` mechanisms.
40
+ #
41
+ # @return [Array<Mechanism>]
42
+ attr_reader :include
43
+
44
+ # Selects all `a:` mechanisms.
45
+ #
46
+ # @return [Array<Mechanism>]
47
+ attr_reader :a
48
+
49
+ # Selects all `mx:` mechanisms.
50
+ #
51
+ # @return [Array<Mechanism>]
52
+ attr_reader :mx
53
+
54
+ # Selects all `ptr:` mechanisms.
55
+ #
56
+ # @return [Array<Mechanism>]
57
+ attr_reader :ptr
58
+
59
+ # Selects all `ip4:` mechanisms.
60
+ #
61
+ # @return [Array<Mechanism>]
62
+ attr_reader :ip4
63
+
64
+ # Selects all `ip6:` mechanisms.
65
+ #
66
+ # @return [Array<Mechanism>]
67
+ attr_reader :ip6
68
+
69
+ # Selects all `exists:` mechanisms.
70
+ #
71
+ # @return [Array<Mechanism>]
72
+ attr_reader :exists
73
+
74
+ # The `redirect=` modifier.
75
+ #
76
+ # @return [Modifier, nil]
77
+ attr_reader :redirect
78
+
79
+ # The `exp=` modifier.
80
+ #
81
+ # @return [Modifier, nil]
82
+ attr_reader :exp
83
+
84
+ #
85
+ # Initializes the SPF record.
86
+ #
87
+ # @param [:spf1] version
88
+ # The SPF version.
89
+ #
90
+ # @param [Array<Mechanism, Modifier>] rules
91
+ # SPF rules.
92
+ #
93
+ def initialize(version,rules=[])
94
+ @version = version
95
+ @rules = rules
96
+
97
+ @mechanisms = @rules.select { |term| term.kind_of?(Mechanism) }
98
+ @modifiers = @rules.select { |term| term.kind_of?(Modifier) }
99
+
100
+ # prefer the last `all:` mechanism
101
+ @all = @mechanisms.reverse_each.find do |mechanism|
102
+ mechanism.name == :all
103
+ end
104
+
105
+ mechanisms_by_name = lambda { |name|
106
+ @mechanisms.select { |mechanism| mechanism.name == name }
107
+ }
108
+
109
+ @include = mechanisms_by_name[:include]
110
+ @a = mechanisms_by_name[:a]
111
+ @mx = mechanisms_by_name[:mx]
112
+ @ptr = mechanisms_by_name[:ptr]
113
+ @ip4 = mechanisms_by_name[:ip4]
114
+ @ip6 = mechanisms_by_name[:ip6]
115
+ @exists = mechanisms_by_name[:exists]
116
+
117
+ modifier_by_name = lambda { |name|
118
+ @modifiers.find { |modifier| modifier.name == name }
119
+ }
120
+
121
+ @redirect = modifier_by_name[:redirect]
122
+ @exp = modifier_by_name[:exp]
123
+ end
124
+
125
+ #
126
+ # Parses an SPF record.
127
+ #
128
+ # @param [String] spf
129
+ # The raw SPF record.
130
+ #
131
+ # @return [Record]
132
+ # The parsed SPF record.
133
+ #
134
+ # @raise [InvalidRecord]
135
+ # The SPF record could not be parsed.
136
+ #
137
+ # @see Parser.parse
138
+ #
139
+ # @api public
140
+ #
141
+ def self.parse(spf)
142
+ Parser.parse(spf)
143
+ rescue Parslet::ParseFailed => error
144
+ raise(InvalidRecord.new(error.message,error.cause))
145
+ end
146
+
147
+ #
148
+ # Queries the domain for it's SPF record.
149
+ #
150
+ # @param [String] domain
151
+ # The domain to query.
152
+ #
153
+ # @param [Resolv::DNS] resolver
154
+ # The optional resolver to use.
155
+ #
156
+ # @return [Record, nil]
157
+ # The parsed SPF record. If no SPF record could be found,
158
+ # `nil` will be returned.
159
+ #
160
+ # @api public
161
+ #
162
+ def self.query(domain,resolver=Resolv::DNS.new)
163
+ if (spf = Query.query(domain,resolver))
164
+ parse(spf)
165
+ end
166
+ end
167
+
168
+ #
169
+ # Enumerates over the rules.
170
+ #
171
+ # @yield [rule]
172
+ # The given block will be passed each rule.
173
+ #
174
+ # @yieldparam [Mechanism, Modifier] rule
175
+ # A directive or modifier rule.
176
+ #
177
+ # @return [Enumerator]
178
+ # If no block is given, an Enumerator will be returned.
179
+ #
180
+ def each(&block)
181
+ @rules.each(&block)
182
+ end
183
+
184
+ #
185
+ # Converts the record back to a String.
186
+ #
187
+ # @return [String]
188
+ #
189
+ def to_s
190
+ "v=#{@version} #{@rules.join(' ')}"
191
+ end
192
+
193
+ #
194
+ # Inspects the record.
195
+ #
196
+ # @return [String]
197
+ #
198
+ def inspect
199
+ "#<#{self.class}: #{self}>"
200
+ end
201
+
202
+ end
203
+ end
204
+ end