lint_fu 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/README.rdoc +20 -0
- data/bin/lint_fu +92 -0
- data/lib/lint_fu/action_pack/model_controller.rb +7 -0
- data/lib/lint_fu/action_pack/model_controller_builder.rb +26 -0
- data/lib/lint_fu/action_pack.rb +2 -0
- data/lib/lint_fu/active_record/model_model.rb +21 -0
- data/lib/lint_fu/active_record/model_model_builder.rb +72 -0
- data/lib/lint_fu/active_record.rb +2 -0
- data/lib/lint_fu/checker.rb +11 -0
- data/lib/lint_fu/issue.rb +45 -0
- data/lib/lint_fu/mixins/file_class_methods.rb +16 -0
- data/lib/lint_fu/mixins/sexp_instance_methods.rb +111 -0
- data/lib/lint_fu/mixins/symbol_instance_methods.rb +13 -0
- data/lib/lint_fu/mixins.rb +3 -0
- data/lib/lint_fu/model_element.rb +48 -0
- data/lib/lint_fu/model_element_builder.rb +29 -0
- data/lib/lint_fu/parser.rb +14 -0
- data/lib/lint_fu/rails/buggy_eager_load_checker.rb +110 -0
- data/lib/lint_fu/rails/model_application.rb +16 -0
- data/lib/lint_fu/rails/model_application_builder.rb +32 -0
- data/lib/lint_fu/rails/scan_builder.rb +43 -0
- data/lib/lint_fu/rails/sql_injection_checker.rb +133 -0
- data/lib/lint_fu/rails/unsafe_find_checker.rb +122 -0
- data/lib/lint_fu/rails.rb +6 -0
- data/lib/lint_fu/report.rb +239 -0
- data/lib/lint_fu/scan.rb +64 -0
- data/lib/lint_fu/source_control/git.rb +53 -0
- data/lib/lint_fu/source_control_provider.rb +58 -0
- data/lib/lint_fu/visitor.rb +36 -0
- data/lib/lint_fu.rb +34 -0
- data/lint_fu.gemspec +35 -0
- metadata +144 -0
@@ -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
|
data/lib/lint_fu/scan.rb
ADDED
@@ -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
|