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