spf-query 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +57 -0
- data/.rspec +1 -0
- data/.travis.yml +18 -0
- data/ChangeLog.md +6 -0
- data/Gemfile +15 -0
- data/LICENSE.txt +20 -0
- data/README.md +75 -0
- data/Rakefile +23 -0
- data/bin/spf-query +28 -0
- data/lib/resolv/dns/resource/in/spf.rb +9 -0
- data/lib/spf/query.rb +3 -0
- data/lib/spf/query/exceptions.rb +8 -0
- data/lib/spf/query/ip.rb +22 -0
- data/lib/spf/query/macro.rb +28 -0
- data/lib/spf/query/macro_string.rb +25 -0
- data/lib/spf/query/mechanism.rb +52 -0
- data/lib/spf/query/modifier.rb +24 -0
- data/lib/spf/query/parser.rb +281 -0
- data/lib/spf/query/query.rb +48 -0
- data/lib/spf/query/record.rb +204 -0
- data/lib/spf/query/version.rb +5 -0
- data/spec/mechanism_spec.rb +65 -0
- data/spec/parser_spec.rb +608 -0
- data/spec/query_spec.rb +38 -0
- data/spec/record_spec.rb +212 -0
- data/spec/spec_helper.rb +9 -0
- data/spf-query.gemspec +26 -0
- metadata +120 -0
@@ -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
|