lint_fu 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,16 @@
1
+ module LintFu
2
+ module Rails
3
+ class ModelApplication
4
+ include LintFu::ModelElement
5
+ include LintFu::SuperModel
6
+
7
+ def controllers
8
+ submodels.select { |m| m.kind_of?(LintFu::ActionPack::ModelController) }
9
+ end
10
+
11
+ def models
12
+ submodels.select { |m| m.kind_of?(LintFu::ActiveRecord::ModelModel) }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ module LintFu
2
+ module Rails
3
+ class ModelApplicationBuilder < ModelElementBuilder
4
+ def initialize(fs_root)
5
+ super()
6
+
7
+ application = ModelApplication.new(fs_root)
8
+
9
+ models_dir = File.join(fs_root, 'app', 'models')
10
+ builder = ActiveRecord::ModelModelBuilder.new
11
+ #TODO ensure the Rails app is using ActiveRecord
12
+ Dir.glob(File.join(models_dir, '**', '*.rb')).each do |f|
13
+ sexp = Parser.parse_ruby(f)
14
+ builder.process(sexp)
15
+ end
16
+ builder.model_elements.each { |elem| application.add_submodel(elem) }
17
+
18
+ controllers_dir = File.join(fs_root, 'app', 'controllers')
19
+ builder = ActionPack::ModelControllerBuilder.new
20
+ Dir.glob(File.join(controllers_dir, '**', '*.rb')).each do |f|
21
+ contents = File.read(f)
22
+ sexp = RubyParser.new.parse(contents)
23
+ sexp.file = f
24
+ builder.process(sexp)
25
+ end
26
+ builder.model_elements.each { |elem| application.add_submodel(elem) }
27
+
28
+ self.model_elements << application
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,43 @@
1
+ module LintFu
2
+ module Rails
3
+ class ScanBuilder
4
+ attr_reader :fs_root
5
+
6
+ def initialize(fs_root)
7
+ @fs_root = fs_root
8
+ end
9
+
10
+ def scan(context)
11
+ scan = LintFu::Scan.new(@fs_root)
12
+
13
+ models_dir = File.join(@fs_root, 'app', 'models')
14
+ controllers_dir = File.join(@fs_root, 'app', 'controllers')
15
+ views_dir = File.join(@fs_root, 'app', 'views')
16
+
17
+ #Scan controllers
18
+ Dir.glob(File.join(controllers_dir, '**', '*.rb')).each do |filename|
19
+ contents = File.read(filename)
20
+ parser = RubyParser.new
21
+ sexp = parser.parse(contents, filename)
22
+ visitor = LintFu::Visitor.new
23
+ visitor.observers << BuggyEagerLoadChecker.new(scan, context, filename)
24
+ visitor.observers << SqlInjectionChecker.new(scan, context, filename)
25
+ visitor.observers << UnsafeFindChecker.new(scan, context, filename)
26
+ visitor.process(sexp)
27
+ end
28
+
29
+ #Scan models
30
+ Dir.glob(File.join(models_dir, '**', '*.rb')).each do |filename|
31
+ contents = File.read(filename)
32
+ parser = RubyParser.new
33
+ sexp = parser.parse(contents, filename)
34
+ visitor = LintFu::Visitor.new
35
+ visitor.observers << SqlInjectionChecker.new(scan, context, filename, 0.2)
36
+ visitor.process(sexp)
37
+ end
38
+
39
+ return scan
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,133 @@
1
+ module LintFu
2
+ module Rails
3
+ class SqlInjection < Issue
4
+ def initialize(scan, file, sexp, subject, confidence=1.0)
5
+ super(scan, file, sexp, confidence)
6
+ @subject = subject
7
+ end
8
+
9
+ def brief
10
+ "SQL Injection"
11
+ end
12
+
13
+ def detail
14
+ return "Could a bad guy insert SQL fragments into <code>#{@subject}</code>?"
15
+ end
16
+
17
+ def reference_info
18
+ return <<EOF
19
+ h4. What is it?
20
+
21
+ A SQL injection vulnerability happens when input from an untrusted source is passed to ActiveRecord in a way that causes it to be interpreted as SQL. If users can inject SQL, they own your database, game over.
22
+
23
+ "Untrusted source" is usually the network but it could be a file on disk, or even a column in the database.
24
+
25
+ h4. When does it happen?
26
+
27
+ The most common source of SQL injection is request parameters that are used without properly escaping them.
28
+
29
+ bc. Account.first(:conditions=>"name like '\#{params[:name]}'")
30
+ User.all(:order=>params[:order_by])
31
+
32
+ h4. How do I fix it?
33
+
34
+ Instead of using query parameters directly, make a habit of _always_ using query parameter replacement:
35
+
36
+ bc. Account.first(:conditions=>[ 'name like ?', params[:name] ])
37
+
38
+ If you cannot use parameter replacement, escape the string manually using @ActiveRecord::Base#sanitize@.
39
+
40
+ bc. User.all(:order=>ActiveRecord::Base.sanitize(params[:order_by]))
41
+ EOF
42
+ end
43
+ end
44
+
45
+ # Visit a Rails controller looking for ActiveRecord queries that contain interpolated
46
+ # strings.
47
+ class SqlInjectionChecker < Checker
48
+ FINDER_REGEXP = /^(find|first|all)(_or_initialize)?(_by_.*_id)?/
49
+ SINK_OPTIONS = Set.new([:conditions, :select, :order, :group, :from, :include, :join])
50
+
51
+ def initialize(scan, context, filename, base_confidence=1.0)
52
+ super(scan, context, filename)
53
+ @class_definition_scope = []
54
+ @base_confidence = base_confidence
55
+ end
56
+
57
+ def observe_class_begin(sexp)
58
+ @class_definition_scope.push sexp
59
+ end
60
+
61
+ def observe_class_end(sexp)
62
+ @class_definition_scope.pop
63
+ end
64
+
65
+ def observe_defn_begin(sexp)
66
+
67
+ @in_method = true
68
+ end
69
+
70
+ def observe_defn_end(sexp)
71
+ @in_method = false
72
+ end
73
+
74
+ def observe_call(sexp)
75
+ return if @class_definition_scope.empty? || !@in_method
76
+
77
+ call = sexp[2].to_s
78
+ arglist = sexp[3]
79
+
80
+ tp = tainted_params(arglist)
81
+ if finder?(call) && !tp.empty?
82
+ scan.issues << SqlInjection.new(scan, self.file, sexp, tp[0].to_ruby_string, @base_confidence)
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def finder?(call)
89
+ # We consider a method call to be a finder if it looks like an AR finder,
90
+ # if it matches the name of ANY named scope, or it matches the name of
91
+ # ANY association. This may create a false positive now and again, but
92
+ # it's better than trying to puzzle out which class/association is being
93
+ # called (until we have type inference and other cool stuff).
94
+ ( call =~ FINDER_REGEXP ||
95
+ self.context.models.detect { |m| m.named_scopes.has_key?(call) } ||
96
+ self.context.models.detect { |m| m.associations.has_key?(call) })
97
+ end
98
+
99
+ def tainted_params(arglist)
100
+ tainted_params = []
101
+
102
+ #Find potentially-tainted members of the arglist's options hash(es)
103
+ hash_params = arglist.find_all_recursively { |se| (Sexp === se) && (se[0] == :hash) }
104
+ hash_params ||= []
105
+
106
+ hash_params.each do |hash|
107
+ hash = hash.clone
108
+ hash.shift #get rid of the leading :hash marker
109
+
110
+ #Iterate through the hash sexp, searching for keys whose values are known
111
+ #to be vulnerable to taint.
112
+ (0...hash.size).each do |n|
113
+ next if n.odd?
114
+ tainted_params << hash[n+1] if (hash[n][0] == :lit) && SINK_OPTIONS.include?(hash[n][1])
115
+ end
116
+ end
117
+
118
+ #Find only those params whose values actually seem to be tainted
119
+ #A param is tainted if it contains a dstr, unless it's an Array
120
+ #in which case it's only tainted if its 0th member is a dstr.
121
+ tainted_params = tainted_params.select do |value|
122
+ next false unless (Sexp === value)
123
+ is_array = (value[0] == :array )
124
+ first_elem_dstr = (is_array && value[1] && value[1][0] == :dstr)
125
+ has_dstr = value.find_recursively { |se| se[0] == :dstr }
126
+ next (!is_array || first_elem_dstr) && has_dstr
127
+ end
128
+
129
+ return tainted_params
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,122 @@
1
+ module LintFu
2
+ module Rails
3
+ class UnsafeFind < Issue
4
+ def initialize(scan, file, sexp, subject)
5
+ super(scan, file, sexp)
6
+ @subject = subject
7
+ end
8
+
9
+
10
+ def detail
11
+ return "Could a bad guy manipulate <code>#{@subject}</code> and get/change stuff he shouldn't?"
12
+ end
13
+
14
+ def reference_info
15
+ return <<EOF
16
+ h4. What is it?
17
+
18
+ An unsafe find is an ActiveRecord query that is performed without checking whether the logged-in user is authorized to view or manipulate the resulting models.
19
+
20
+ h4. When does it happen?
21
+
22
+ Some trivial examples:
23
+
24
+ bc. BankAccount.first(params[:id]).destroy
25
+ Account.all(:conditions=>{:nickname=>params[:nickname]})
26
+
27
+ In reality, it is often hard to determine whether a find is safe. Authorization can happen in many ways and the "right" way to do it depends on the application requirements.
28
+
29
+ Here are some things to consider when evaluating whether a find is safe:
30
+
31
+ * Is authorization checked beforehand or afterward, e.g. by checking ownership of the model?
32
+ * Do the query's conditions scope it in some way to the current user or account?
33
+ * How will the results be used? What information is displayed in the view?
34
+ * Are the results scoped afterward, e.g. by calling @select@ on the result set?
35
+
36
+ h4. How do I fix it?
37
+
38
+ Use named scopes to scope your queries instead of calling the class-level finders:
39
+
40
+ bc. current_user.bank_accounts.first(params[:id])
41
+
42
+ If a named scope is not convenient, include conditions that scope the query:
43
+
44
+ bc. BankAccount.find(:conditions=>{:owner_id=>current_user})
45
+
46
+ If your authorization rules are so complex that neither of those approaches work, always make sure to perform authorization yourself:
47
+
48
+ bc. #My bank allows customers to access ANY account on their birthday
49
+ @bank_account = BankAccount.first(params[:id])
50
+ raise ActiveRecord::RecordNotFound unless current_user.born_on = Date.today
51
+ EOF
52
+ end
53
+ end
54
+
55
+ # Visit a Rails controller looking for ActiveRecord finders being called in a way that
56
+ # might allow an attacker to perform unauthorized operations on resources, e.g. creating,
57
+ # updating or deleting someone else's records.
58
+ class UnsafeFindChecker < Checker
59
+ FINDER_REGEXP = /^(find|first|all)(_or_initialize)?(_by_.*_id)?/
60
+
61
+ #sexp:: s(:class, <class_name>, <superclass>, s(:scope, <class_definition>))
62
+ def observe_class_begin(sexp)
63
+ #TODO get rid of RightScale-specific assumption
64
+ @in_admin_controller = !!(sexp[1].to_ruby_string =~ /^Admin/)
65
+ end
66
+
67
+ #sexp:: s(:class, <class_name>, <superclass>, s(:scope, <class_definition>))
68
+ def observe_class_end(sexp)
69
+ @in_admin_controller = false
70
+ end
71
+
72
+ #sexp:: s(:call, <target>, <method_name>, s(:arglist))
73
+ def observe_call(sexp)
74
+ check_suspicious_finder(sexp)
75
+ end
76
+
77
+ protected
78
+
79
+ def check_suspicious_finder(sexp)
80
+ return if @in_admin_controller
81
+
82
+ #sexp:: :call, <target>, <method_name>, <argslist...>
83
+ if (sexp[1] != nil) && (sexp[1][0] == :const || sexp[1][0] == :colon2)
84
+ name = sexp[1].to_ruby_string
85
+ type = self.context.models.detect { |m| m.modeled_class_name == name }
86
+ call = sexp[2].to_s
87
+ params = sexp[3]
88
+ if finder?(type, call) && !params.constant? && !sexp_contains_scope?(params)
89
+ scan.issues << UnsafeFind.new(scan, self.file, sexp, params.to_ruby_string)
90
+ end
91
+ end
92
+ end
93
+
94
+ def finder?(type, call)
95
+ type.kind_of?(LintFu::ActiveRecord::ModelModel) &&
96
+ ( call =~ FINDER_REGEXP || type.associations.has_key?(call) )
97
+ end
98
+
99
+ def sexp_contains_scope?(sexp)
100
+ return false if !sexp.kind_of?(Sexp) || sexp.empty?
101
+
102
+ sexp_type = sexp[0]
103
+
104
+ #If calling a method -- check to see if we're accessing a current_* method
105
+ if (sexp_type == :call) && (sexp[1] == nil)
106
+ #TODO get rid of RightScale-specific assumptions
107
+ return true if (sexp[2] == :current_user)
108
+ return true if (sexp[2] == :current_account)
109
+ end
110
+
111
+ #Generic case: check all subexpressions of the sexp
112
+ sexp.each do |subexp|
113
+ if subexp.kind_of?(Sexp)
114
+ return true if sexp_contains_scope?(subexp)
115
+ end
116
+ end
117
+
118
+ return false
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,6 @@
1
+ require 'lint_fu/rails/model_application'
2
+ require 'lint_fu/rails/model_application_builder'
3
+ require 'lint_fu/rails/buggy_eager_load_checker'
4
+ require 'lint_fu/rails/sql_injection_checker'
5
+ require 'lint_fu/rails/unsafe_find_checker'
6
+ require 'lint_fu/rails/scan_builder'
@@ -0,0 +1,239 @@
1
+
2
+ module LintFu
3
+ class Report
4
+ attr_reader :scan, :scm
5
+
6
+ def initialize(scan, scm, included_issues)
7
+ @scan = scan
8
+ @scm = scm
9
+ @issues = included_issues
10
+ end
11
+
12
+ def generate(output_stream)
13
+ raise NotImplemented
14
+ end
15
+ end
16
+
17
+ class HtmlReport < Report
18
+ STYLESHEET = <<-EOF
19
+ table { border: 2px solid black }
20
+ th { color: white; background: black }
21
+ td { border-right: 1px dotted black; border-bottom: 1px dotted black }
22
+ h1 { font-family: Arial,Helvetica,sans-serif }
23
+ h2 { font-family: Arial,Helvetica,sans-serif; color: white; background: black }
24
+ h3 { font-family: Arial,Helvetica,sans-serif }
25
+ h4 { border-bottom: 1px dotted grey }
26
+ .detail code { background: yellow }
27
+ EOF
28
+
29
+ def generate(output_stream)
30
+ #Build map of contributors to issues they created
31
+
32
+ @issues_by_author = Hash.new
33
+
34
+ @issues.each do |issue|
35
+ commit, author = scm.blame(issue.relative_file, issue.line)
36
+ @issues_by_author[author] ||= []
37
+ @issues_by_author[author] << issue
38
+ end
39
+ #Sort contributors in decreasing order of number of issues created
40
+ @authors_by_issue_count = @issues_by_author.keys.sort { |x,y| @issues_by_author[y].size <=> @issues_by_author[x].size }
41
+
42
+ @issues_by_class = Hash.new
43
+ @issues.each do |issue|
44
+ klass = issue.class
45
+ @issues_by_class[klass] ||= []
46
+ @issues_by_class[klass] << issue
47
+ end
48
+
49
+ #Build map of files to issues they contain
50
+ @issues_by_file = Hash.new
51
+ @issues.each do |issue|
52
+ @issues_by_file[issue.relative_file] ||= []
53
+ @issues_by_file[issue.relative_file] << issue
54
+ end
55
+ #Sort files in decreasing order of number of issues contained
56
+ @files_by_issue_count = @issues_by_file.keys.sort { |x,y| @issues_by_file[y].size <=> @issues_by_file[x].size }
57
+ #Sort files in increasing lexical order of path
58
+ @files_by_name = @issues_by_file.keys.sort { |x,y| x <=> y }
59
+
60
+ #Write the report in HTML format
61
+ x = Builder::XmlMarkup.new(:target => output_stream, :indent => 2)
62
+ x << %Q{<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n}
63
+ x.html do |html|
64
+ html.head do |head|
65
+ head.title 'Static Analysis Results'
66
+ include_external_javascripts(head)
67
+ include_external_stylesheets(head)
68
+ head.style(STYLESHEET, :type=>'text/css')
69
+ end
70
+ html.body do |body|
71
+ body.h1 'Summary'
72
+ body.p "#{@issues.size} issues found."
73
+
74
+ body.h2 'Issues by Type'
75
+ body.table do |table|
76
+ table.thead do |thead|
77
+ thead.tr do |tr|
78
+ tr.th 'Type'
79
+ tr.th '#'
80
+ tr.th 'Issues'
81
+ end
82
+
83
+ @issues_by_class.each_pair do |klass, issues|
84
+ table.tr do |tr|
85
+ sample_issue = issues.first
86
+ tr.td(sample_issue.brief)
87
+ tr.td(issues.size.to_s)
88
+ tr.td do |td|
89
+ issues.each do |issue|
90
+ td.a(issue.issue_hash[0..4], :href=>"#issue_#{issue.issue_hash}")
91
+ td.text!(' ')
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ body.h2 'Issues by Contributor'
100
+ body.table do |table|
101
+ table.thead do |thead|
102
+ thead.tr do |tr|
103
+ tr.th 'Name'
104
+ tr.th '#'
105
+ tr.th 'Issues'
106
+ end
107
+ end
108
+ table.tbody do |tbody|
109
+ @authors_by_issue_count.each do |author|
110
+ table.tr do |tr|
111
+ tr.td(author)
112
+ tr.td(@issues_by_author[author].size.to_s)
113
+ tr.td do |td|
114
+ @issues_by_author[author].each do |issue|
115
+ td.a(issue.issue_hash[0..4], :href=>"#issue_#{issue.issue_hash}")
116
+ td.text!(' ')
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ body.h2 'Issues by File'
125
+ body.table do |table|
126
+ table.thead do |thead|
127
+ thead.tr do |tr|
128
+ tr.th 'File'
129
+ tr.th '#'
130
+ tr.th 'Issues'
131
+ end
132
+ end
133
+ table.tbody do |tbody|
134
+ @files_by_issue_count.each do |file|
135
+ tbody.tr do |tr|
136
+ tr.td(file)
137
+ tr.td(@issues_by_file[file].size.to_s)
138
+ tr.td do |td|
139
+ @issues_by_file[file].each do |issue|
140
+ td.a(issue.issue_hash[0..4], :href=>"#issue_#{issue.issue_hash}")
141
+ td.text!(' ')
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ body.h1 'Detailed Results'
150
+ @files_by_name.each do |file|
151
+ body.h2 file
152
+
153
+ issues = @issues_by_file[file]
154
+ issues = issues.to_a.sort { |x,y| x.line <=> y.line }
155
+ issues.each do |issue|
156
+ body.div(:class=>'issue', :id=>"issue_#{issue.issue_hash}") do |div_issue|
157
+ div_issue.h4 do |h4|
158
+ reference_link(div_issue, issue)
159
+ h4.text! ", #{File.basename(issue.file)}:#{issue.line}"
160
+ end
161
+ div_issue.div(:class=>'detail') do |div_detail|
162
+ div_detail << RedCloth.new(issue.detail).to_html
163
+ end
164
+
165
+ first = issue.line-3
166
+ first = 1 if first < 1
167
+ last = issue.line + 3
168
+ excerpt = scm.excerpt(issue.file, (first..last), :blame=>false)
169
+ highlighted_code_snippet(div_issue, excerpt, first, issue.line)
170
+ end
171
+ end
172
+ end
173
+
174
+ body.h1 'Reference Information'
175
+ @issues_by_class.values.each do |array|
176
+ sample_issue = array.first
177
+ body.h2 sample_issue.brief
178
+ body.div(:id=>id_for_issue_class(sample_issue.class)) do |div|
179
+ div << RedCloth.new(sample_issue.reference_info).to_html
180
+ end
181
+ end
182
+
183
+ activate_syntax_highlighter(body)
184
+ end
185
+ end
186
+ end
187
+
188
+ private
189
+
190
+ def reference_link(parent, issue)
191
+ href = "#TB_inline?width=800&height=600&inlineId=#{id_for_issue_class(issue.class)}"
192
+ parent.a(issue.brief, :href=>href.to_sym, :title=>issue.brief, :class=>'thickbox')
193
+ end
194
+
195
+ def highlighted_code_snippet(parent, snippet, first_line, highlight)
196
+ parent.pre(snippet, :class=>"brush: ruby; first-line: #{first_line}; highlight: [#{highlight}]")
197
+ end
198
+
199
+ def include_external_javascripts(head)
200
+ #Note that we pass an empty block {} to the script tag in order to make
201
+ #Builder create a beginning-and-end tag; most browsers won't parse
202
+ #"empty" script tags even if they point to a src!!!
203
+ head.script(:src=>'http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js', :type=>'text/javascript') {}
204
+ head.script(:src=>'https://s3.amazonaws.com/lint_fu/assets/js/thickbox.min.js', :type=>'text/javascript') {}
205
+ head.script(:src=>'https://s3.amazonaws.com/lint_fu/assets/js/xregexp.min.js', :type=>'text/javascript') {}
206
+ head.script(:src=>'https://s3.amazonaws.com/lint_fu/assets/js/shCore.js', :type=>'text/javascript') {}
207
+ head.script(:src=>'https://s3.amazonaws.com/lint_fu/assets/js/shBrushRuby.js', :type=>'text/javascript') {}
208
+ end
209
+
210
+ def include_external_stylesheets(head)
211
+ head.link(:rel=>'stylesheet', :type=>'text/css', :href=>'https://s3.amazonaws.com/lint_fu/assets/css/thickbox.css')
212
+ head.link(:rel=>'stylesheet', :type=>'text/css', :href=>'https://s3.amazonaws.com/lint_fu/assets/css/shCore.css')
213
+ head.link(:rel=>'stylesheet', :type=>'text/css', :href=>'https://s3.amazonaws.com/lint_fu/assets/css/shThemeDefault.css')
214
+ end
215
+
216
+ def id_for_issue_class(klass)
217
+ "reference_#{klass.name.split('::')[-1].underscore}"
218
+ end
219
+
220
+ def activate_syntax_highlighter(body)
221
+ body.script('SyntaxHighlighter.all()', :type=>'text/javascript')
222
+ end
223
+ end
224
+
225
+ class TextReport < Report
226
+ def generate(output_stream)
227
+ counter = 1
228
+
229
+ @issues.each do |issue|
230
+ #commit, author = scm.blame(issue.relative_file, issue.line)
231
+ output_stream.puts " #{counter}) Failure:"
232
+ output_stream.puts "#{issue.brief}, #{issue.relative_file}:#{issue.line}"
233
+ output_stream.puts
234
+ output_stream.puts
235
+ counter += 1
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,64 @@
1
+ module LintFu
2
+ class ScanNotFinalized < Exception; end
3
+
4
+ class Scan
5
+ COMMENT = /^\s*#/
6
+ VERBOSE_BLESSING_COMMENT = /#\s*(lint|security)\s*[-:]\s*not\s*a?n?\s*([a-z0-9 ]*) ?(as|because|;)\s*(.*)/i
7
+ BLESSING_COMMENT = /#\s*(lint|security)\s*[-:]\s*not\s*a?n?\s*([a-z0-9 ]*)/i
8
+
9
+ attr_reader :fs_root, :issues
10
+
11
+ def initialize(fs_root)
12
+ @fs_root = fs_root
13
+ @issues = Set.new
14
+ end
15
+
16
+ def blessed?(issue)
17
+ comments = preceeding_comments(issue.sexp)
18
+ return false unless comments
19
+
20
+ match = nil
21
+ comments.each do |line|
22
+ match = VERBOSE_BLESSING_COMMENT.match(line)
23
+ match = BLESSING_COMMENT.match(line) unless match
24
+ break if match
25
+ end
26
+
27
+ return false unless match
28
+ blessed_issue_class = match[2].downcase.split(/\s+/).join('_').camelize
29
+
30
+ # Determine whether the blessed issue class appears anywhere in the class hierarchy of
31
+ # issue_class.
32
+ klass = issue.class
33
+ while klass
34
+ return true if klass.name.index(blessed_issue_class)
35
+ klass = klass.superclass
36
+ end
37
+
38
+ return false
39
+ end
40
+
41
+ def preceeding_comments(sexp)
42
+ @file_contents ||= {}
43
+ @file_contents[sexp.file] ||= File.readlines(sexp.file)
44
+ cont = @file_contents[sexp.file]
45
+
46
+ comments = ''
47
+
48
+ max_line = sexp.line - 1 - 1
49
+ max_line = 0 if max_line < 0
50
+ min_line = max_line
51
+
52
+ while cont[min_line] =~ COMMENT && min_line >= 0
53
+ min_line -= 1
54
+ end
55
+
56
+ if cont[max_line] =~ COMMENT
57
+ min_line +=1 unless min_line == max_line
58
+ return cont[min_line..max_line]
59
+ else
60
+ return nil
61
+ end
62
+ end
63
+ end
64
+ end