lint_fu 0.5.0

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