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.
- 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
|