spf-query 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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