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 ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Tony Spataro
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,20 @@
1
+ = LintFu
2
+
3
+ Lint-Fu uses some very basic static analysis tricks to find bugs in your Rails
4
+ app. It produces a report (HTML format by default) explaining what it found.
5
+
6
+ Although Lint-Fu was built for Rails applications, it is modular and extensible
7
+ and rules can be developed for any framework or toolkit, or even for Ruby code
8
+ in general.
9
+
10
+ Lint-Fu's capabilities are limited to semantic and structural analysis of the
11
+ code; it does model data or control flow. This means it spots a *very small*
12
+ subset of all potential bugs that it knows how to look for. Furthermore, it
13
+ will sometimes report false positives.
14
+
15
+ = Example
16
+
17
+ $ rake lint
18
+
19
+
20
+ Copyright (c) 2009 Tony Spataro, released under the MIT license
data/bin/lint_fu ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ basedir = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
4
+ $LOAD_PATH.unshift(basedir) unless $LOAD_PATH.include?(basedir)
5
+
6
+ require 'lint_fu'
7
+
8
+ def timed(activity)
9
+ print activity, '...'
10
+ STDOUT.flush
11
+ t0 = Time.now.to_i
12
+ yield
13
+ t1 = Time.now.to_i
14
+ dt = t1-t0
15
+ if dt > 0
16
+ puts "done (#{t1-t0} sec)"
17
+ STDOUT.flush
18
+ else
19
+ puts "done"
20
+ STDOUT.flush
21
+ end
22
+ rescue Exception => e
23
+ puts 'error!' #ensure we print a newline
24
+ raise e
25
+ end
26
+
27
+ app_root = File.expand_path('.')
28
+
29
+ #Only define the Rake tasks if the plugin loaded successfully
30
+ @scm = LintFu::SourceControlProvider.for_directory(app_root)
31
+
32
+ #Build a model of the application we are scanning.
33
+ timed("Build a model of the application") do
34
+ builder = LintFu::Rails::ModelApplicationBuilder.new(app_root)
35
+ @application = builder.model_elements[0]
36
+ raise LintFu::ProviderError.new("Unable to identify the source control provider for #{app_root}") unless @scm
37
+ end
38
+
39
+ #Using the model we built, scan the controllers for security bugs.
40
+ timed("Scan the application") do
41
+ builder = LintFu::Rails::ScanBuilder.new(app_root)
42
+ @scan = builder.scan(@application)
43
+ end
44
+
45
+ @genuine_issues = @scan.issues.select { |i| !@scan.blessed?(i) }
46
+ if @genuine_issues.empty?
47
+ puts "Clean scan: no issues found. Skipping report."
48
+ exit(0)
49
+ end
50
+
51
+ #CruiseControl.rb integration: write our report to the CC build artifacts folder
52
+ output_dir = ENV['CC_BUILD_ARTIFACTS'] || app_root
53
+ mkdir_p output_dir unless File.directory?(output_dir)
54
+
55
+ flavor = ENV['FORMAT'] || 'html'
56
+ typename = "#{flavor}_report".camelize
57
+
58
+ #Use a filename (or STDOUT) for our report that corresponds to its format
59
+ case flavor
60
+ when 'html'
61
+ output_name = File.join(output_dir, 'lint.html')
62
+ output = File.open(output_name, 'w')
63
+ when 'text'
64
+ output = STDOUT
65
+ else
66
+ puts "Unrecognized output format #{flavor} (undefined type #{typename})"
67
+ exit -1
68
+ end
69
+
70
+ klass = LintFu.const_get(typename.to_sym)
71
+
72
+ timed("Generate report") do
73
+ klass.new(@scan, @scm, @genuine_issues).generate(output)
74
+ output.close
75
+ end
76
+
77
+ #Support automation jobs that need to distinguish between failure due to
78
+ #broken environment and failure to due issues that were genuinely found by
79
+ #the lint task.
80
+ if ENV['STATUS_IF_ISSUES']
81
+ if(@genuine_issues.size > 0)
82
+ retval = ENV['STATUS_IF_ISSUES'].to_i
83
+ else
84
+ retval = 0
85
+ end
86
+ else
87
+ retval = [@genuine_issues.size, 255].min
88
+ end
89
+
90
+ system("open #{output_name}") if (output != STDOUT && STDOUT.tty?)
91
+
92
+ exit( retval )
@@ -0,0 +1,7 @@
1
+ module LintFu
2
+ module ActionPack
3
+ class ModelController
4
+ include LintFu::ModelElement
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ module LintFu
2
+ module ActionPack
3
+ class ModelControllerBuilder < ModelElementBuilder
4
+ SIGNATURE_SEXP = s(:colon2, s(:const, :ActionController), :Base)
5
+
6
+ #sexp:: [:class, <classname>, <superclass|nil>, <CLASS DEFS>]
7
+ def process_class(sexp)
8
+ return super(sexp) unless sexp[2] && sexp[2] == SIGNATURE_SEXP
9
+
10
+ unless @current_model_element
11
+ @current_model_element = ModelController.new(sexp, self.namespace)
12
+ did_element = true
13
+ end
14
+
15
+ ret = super(sexp)
16
+
17
+ if did_element
18
+ self.model_elements.push @current_model_element
19
+ @current_model_element = nil
20
+ end
21
+
22
+ return ret
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,2 @@
1
+ require 'lint_fu/action_pack/model_controller'
2
+ require 'lint_fu/action_pack/model_controller_builder'
@@ -0,0 +1,21 @@
1
+ module LintFu
2
+ module ActiveRecord
3
+ class ModelModel
4
+ include LintFu::ModelElement
5
+
6
+ attr_reader :associations
7
+ attr_reader :named_scopes
8
+ attr_writer :paranoid
9
+
10
+ def initialize(sexp, namespace=nil)
11
+ super(sexp, namespace)
12
+ @associations = {}
13
+ @named_scopes = {}
14
+ end
15
+
16
+ def paranoid?
17
+ !!@paranoid
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,72 @@
1
+ module LintFu
2
+ module ActiveRecord
3
+ class ModelModelBuilder < ModelElementBuilder
4
+ SIGNATURE_SEXP = s(:colon2, s(:const, :ActiveRecord), :Base)
5
+
6
+ SINGULAR_ASSOCS = Set.new([:belongs_to, :has_one])
7
+ PLURAL_ASSOCS = Set.new([:has_many, :has_and_belongs_to_many])
8
+ ASSOCS = SINGULAR_ASSOCS + PLURAL_ASSOCS
9
+
10
+ #sexp:: [:class, <classname>, <superclass|nil>, <CLASS DEFS>]
11
+ def process_class(sexp)
12
+ return super(sexp) unless sexp[2] && sexp[2] == SIGNATURE_SEXP
13
+
14
+ unless @current_model_element
15
+ @current_model_element = ModelModel.new(sexp, self.namespace)
16
+ did_element = true
17
+ end
18
+
19
+ ret = super(sexp)
20
+
21
+ if did_element
22
+ self.model_elements.push @current_model_element
23
+ @current_model_element = nil
24
+ end
25
+
26
+ return ret
27
+ end
28
+
29
+ #s(:call, nil, :belongs_to, s(:arglist, s(:lit, :relation_name)))
30
+ def process_call(sexp)
31
+ return sexp unless @current_model_element
32
+
33
+ callee, method, arglist = sexp[1], sexp[2], sexp[3]
34
+ arglist = nil unless arglist[0] == :arglist
35
+ discover_associations(callee, method, arglist)
36
+ discover_named_scopes(callee, method, arglist)
37
+ discover_paranoia(callee, method, arglist)
38
+ return sexp
39
+ end
40
+
41
+ private
42
+ def discover_associations(callee, method, arglist)
43
+ #Is the call declaring an ActiveRecord association?
44
+ if (callee == nil) && ASSOCS.include?(method) && arglist
45
+ assoc_name = arglist[1].to_ruby
46
+
47
+ # TODO grok :class_name option
48
+ if SINGULAR_ASSOCS.include?(method)
49
+ assoc_class_name = assoc_name.to_s.camelize
50
+ else
51
+ assoc_class_name = assoc_name.to_s.singularize.camelize
52
+ end
53
+
54
+ @current_model_element.associations[assoc_name] = assoc_class_name
55
+ end
56
+ end
57
+
58
+ def discover_named_scopes(callee, method, arglist)
59
+ #Is the call declaring a named scope?
60
+ if (callee == nil && method == :named_scope) && arglist
61
+ scope_name = arglist[1].to_ruby
62
+ scope_args = arglist[2..-1].to_ruby(:partial=>nil)
63
+ @current_model_element.named_scopes[scope_name] = scope_args
64
+ end
65
+ end
66
+
67
+ def discover_paranoia(callee, method, arglist)
68
+ @current_model_element.paranoid = true if (method == :acts_as_paranoid)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,2 @@
1
+ require 'lint_fu/active_record/model_model'
2
+ require 'lint_fu/active_record/model_model_builder'
@@ -0,0 +1,11 @@
1
+ module LintFu
2
+ class Checker
3
+ attr_reader :scan, :context, :file
4
+
5
+ def initialize(scan, context, file=nil)
6
+ @scan = scan
7
+ @context = context
8
+ @file = file
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,45 @@
1
+ module LintFu
2
+ class Issue
3
+ attr_reader :file, :sexp, :confidence
4
+
5
+ def initialize(scan, file, sexp, confidence=1.0)
6
+ @scan = scan
7
+ @file = file
8
+ @sexp = sexp.deep_clone
9
+ self.confidence = confidence
10
+ end
11
+
12
+ def line
13
+ @sexp.line
14
+ end
15
+
16
+ def relative_file
17
+ File.relative_path(@scan.fs_root, file)
18
+ end
19
+
20
+ def brief
21
+ self.class.name.split('::')[-1].underscore.gsub('_', ' ').titleize
22
+ end
23
+
24
+ def detail
25
+ "There is an issue at #{file_basename}:#{line}."
26
+ end
27
+
28
+ def reference_info
29
+ "No reference information is available for #{brief}."
30
+ end
31
+
32
+ def file_basename
33
+ File.basename(file)
34
+ end
35
+
36
+ def issue_hash()
37
+ Digest::SHA1.hexdigest("#{self.class.name} - #{self.relative_file} - #{sexp.fingerprint}")
38
+ end
39
+
40
+ def confidence=(confidence)
41
+ raise ArgumentError, "Confidence must be a real number in the range (0..1)" unless (0..1).include?(confidence)
42
+ @confidence = confidence
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,16 @@
1
+ module LintFu
2
+ module Mixins
3
+ module FileClassMethods
4
+ def relative_path(base, path)
5
+ base = File.expand_path(base)
6
+ path = File.expand_path(path)
7
+ raise Errno::ENOENT unless path.index(base) == 0
8
+ return path[base.length+1..-1]
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ class <<File
15
+ include LintFu::Mixins::FileClassMethods
16
+ end
@@ -0,0 +1,111 @@
1
+ module LintFu
2
+ module Mixins
3
+ module SexpInstanceMethods
4
+ def deep_clone
5
+ Marshal.load(Marshal.dump(self))
6
+ end
7
+
8
+ #Return a version of this Sexp that preserves the structure of the original, but with
9
+ #any specific names, quantities or other values replaced with nil. The fingerprint of
10
+ #a given chunk of code will tend to remain the same over time, even if variable names
11
+ #or other inconsequential details are changed.
12
+ #TODO actually implement this method
13
+ def fingerprint
14
+ self
15
+ end
16
+
17
+ #Generate a human-readable description for this sexp that is similar to source code.
18
+ def to_ruby_string
19
+ Ruby2Ruby.new.process(self.deep_clone)
20
+ end
21
+
22
+ # Translate a sexp containing a Ruby data literal (string, int, array, hash, etc) into the equivalent Ruby object.
23
+ def to_ruby(options={})
24
+ typ = self[0]
25
+
26
+ case typ
27
+ when :true
28
+ return true
29
+ when :false
30
+ return false
31
+ when :lit, :str
32
+ return self[1]
33
+ when :array, :arglist
34
+ return self[1..-1].collect { |x| x.to_ruby(options) }
35
+ when :hash
36
+ result = {}
37
+ key, value = nil, nil
38
+ flipflop = false
39
+ self[1..-1].each do |token|
40
+ if flipflop
41
+ value = token
42
+ result[key.to_ruby(options)] = value.to_ruby(options)
43
+ else
44
+ key = token
45
+ end
46
+ flipflop = !flipflop
47
+ end
48
+ return result
49
+ else
50
+ return options[:partial] if options.has_key?(:partial)
51
+ raise StandardError.new("Cannot convert Sexp to Ruby object: " + self.to_s)
52
+ end
53
+ end
54
+
55
+ def constant?
56
+ typ = self[0]
57
+
58
+ case typ
59
+ when :true, :false, :lit, :str
60
+ return true
61
+ when :array, :arglist
62
+ self[1..-1].each { |sexp| return false unless sexp.constant? }
63
+ return true
64
+ when :hash
65
+ result = {}
66
+ key, value = nil, nil
67
+ flipflop = false
68
+ self[1..-1].each do |token|
69
+ if flipflop
70
+ value = token
71
+ return false unless key.constant? && value.constant?
72
+ else
73
+ key = token
74
+ end
75
+ flipflop = !flipflop
76
+ end
77
+ return true
78
+ else
79
+ return false
80
+ end
81
+ end
82
+
83
+ def find_recursively(&test)
84
+ return self if test.call(self)
85
+
86
+ self.each do |child|
87
+ found = child.find_recursively(&test) if (Sexp === child)
88
+ return found if found
89
+ end
90
+
91
+ return nil
92
+ end
93
+
94
+ def find_all_recursively(results=nil, &test)
95
+ results ||= []
96
+ results << self if test.call(self)
97
+
98
+ self.each do |child|
99
+ child.find_all_recursively(results, &test) if (Sexp === child)
100
+ end
101
+
102
+ return nil if results.empty?
103
+ return results
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ Sexp.instance_eval do
110
+ include LintFu::Mixins::SexpInstanceMethods
111
+ end
@@ -0,0 +1,13 @@
1
+ module LintFu
2
+ module Mixins
3
+ module SymbolInstanceMethods
4
+ def to_ruby_string
5
+ self.to_s
6
+ end
7
+ end
8
+ end
9
+ end
10
+
11
+ Symbol.instance_eval do
12
+ include LintFu::Mixins::SymbolInstanceMethods
13
+ end
@@ -0,0 +1,3 @@
1
+ require 'lint_fu/mixins/file_class_methods'
2
+ require 'lint_fu/mixins/sexp_instance_methods'
3
+ require 'lint_fu/mixins/symbol_instance_methods'
@@ -0,0 +1,48 @@
1
+ module LintFu
2
+ # An element of a static analysis model that contains, or consists of, submodels. For instance,
3
+ # an Application might consists of Models, Controllers and Views.
4
+ module SuperModel
5
+ def submodels
6
+ return [].freeze unless @submodels
7
+ @submodels.dup.freeze
8
+ end
9
+
10
+ def each_submodel(&block)
11
+ @submodels ||= Set.new()
12
+ @submodels.each(&block)
13
+ end
14
+
15
+ def add_submodel(sub)
16
+ @submodels ||= Set.new()
17
+ @submodels << sub
18
+ end
19
+
20
+ def remove_submodel(sub)
21
+ @submodels ||= Set.new()
22
+ @submodels.delete sub
23
+ end
24
+ end
25
+
26
+ # An element of the static analysis model being created; generally corresponds to a
27
+ # class (e.g. model, controller or view) within the application being scanned.
28
+ module ModelElement
29
+ attr_accessor :supermodel
30
+ attr_reader :modeled_class_name, :modeled_class_superclass_name, :parse_tree
31
+
32
+ #sexp:: [:class, <classname>, <superclass|nil>, <CLASS DEFS>]
33
+ #namespace:: Array of enclosing module names for this class
34
+ def initialize(sexp, namespace=nil)
35
+ @parse_tree = sexp
36
+ if namespace
37
+ @modeled_class_name = namespace.join('::') + (namespace.empty? ? '' : '::') + sexp[1].to_s
38
+ else
39
+ @modeled_class_name = sexp[1]
40
+ end
41
+ end
42
+
43
+ #Have a pretty string representation
44
+ def to_s
45
+ "<<model of #{modeled_class_name}>>"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ module LintFu
2
+ class ModelElementBuilder < SexpProcessor
3
+ attr_reader :model_elements, :namespace
4
+
5
+ def initialize(namespace=nil)
6
+ super()
7
+ @model_elements = []
8
+ @namespace = namespace || []
9
+ self.require_empty = false
10
+ self.auto_shift_type = false
11
+ end
12
+
13
+ #sexp:: [:module, <modulename>, <:scope - MODULE DEFS>]
14
+ def process_module(sexp)
15
+ @namespace.push sexp[1]
16
+ process(sexp[2])
17
+ @namespace.pop
18
+ return sexp
19
+ end
20
+
21
+ #sexp:: [:class, <classname>, <superclass|nil>, <:scope - CLASS DEFS>]
22
+ def process_class(sexp)
23
+ @namespace.push sexp[1]
24
+ process(sexp[3])
25
+ @namespace.pop
26
+ return sexp
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,14 @@
1
+ module LintFu
2
+ module Parser
3
+ def self.parse_ruby(filename)
4
+ contents = File.read(filename)
5
+ sexp = RubyParser.new.parse(contents)
6
+ sexp.file = filename
7
+ return sexp
8
+ rescue SyntaxError => e
9
+ e2 = SyntaxError.new "In #{filename}: #{e.message}"
10
+ e2.set_backtrace(e.backtrace)
11
+ raise e2
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,110 @@
1
+ module LintFu
2
+ module Rails
3
+ class BuggyEagerLoad < Issue
4
+ def initialize(scan, file, sexp, subject)
5
+ super(scan, file, sexp)
6
+ @subject = subject
7
+ end
8
+
9
+ def detail
10
+ "Instances of the paranoid model <code>#{@subject}</code> are being eager-loaded. This may cause unexpected results."
11
+ end
12
+
13
+ def reference_info
14
+ return <<EOF
15
+ h4. What is it?
16
+
17
+ A buggy eager load happens when an ActiveRecord finder performs eager loading of a @:has_many@ association and the "target" of the association acts as paranoid.
18
+
19
+ The acts_as_paranoid plugin does not correctly eager loads that use a @JOIN@ strategy. If a paranoid model is eager loaded in this way, _all_ models -- even deleted ones -- will be loaded.
20
+
21
+ h4. When does it happen?
22
+
23
+ A finder with any of the following properties will cause Rails to eager-load using @JOIN@
24
+
25
+ * complex @:conditions@ option (containing SQL fragments, or referring to tables using strings)
26
+ * complex @:order@
27
+ * complex @:join@
28
+ * complex @:include@
29
+ * use of named scopes (which almost always add complex options to the query)
30
+
31
+ If your find exhibits any of these properties and it @:include@s a paranoid model, then you have a problem.
32
+
33
+ h4. How do I fix it?
34
+
35
+ Avoid doing complex finds at the same time you @:include@ a paranoid model.
36
+
37
+ EOF
38
+ end
39
+ end
40
+
41
+ # Visit a Rails controller looking for troublesome stuff
42
+ class BuggyEagerLoadChecker < Checker
43
+ FINDER_REGEXP = /^(find|first|all)(_or_initialize)?(_by_.*)?/
44
+
45
+ #sexp:: s(:call, <target>, <method_name>, s(:arglist))
46
+ def observe_call(sexp)
47
+ return unless finder?(sexp)
48
+ if finder?(sexp) && (si = spotty_includes(sexp[3]))
49
+ scan.issues << BuggyEagerLoad.new(scan, self.file, sexp, si.modeled_class_name)
50
+ end
51
+ end
52
+
53
+ protected
54
+
55
+ def finder?(sexp)
56
+ !!(sexp[2].to_s =~ FINDER_REGEXP)
57
+ end
58
+
59
+ def spotty_includes(sexp)
60
+ #transform the sexp (if it's an arglist) into a hash for easier scrutiny
61
+ arglist = ( sexp && (sexp[0] == :arglist) && sexp.to_ruby(:partial=>nil) )
62
+ #no dice unless we're looking at an arglist
63
+ return nil unless arglist
64
+
65
+ #no options hash in arglist? no problem!
66
+ return nil unless (options = arglist.last).is_a?(Hash)
67
+
68
+ does_eager_loading = options.has_key?(:include)
69
+ has_complexity_prone_params =
70
+ options.has_key?(:conditions) ||
71
+ options.has_key?(:order) ||
72
+ options.has_key?(:joins) ||
73
+ options.has_key?(:group)
74
+
75
+ #no eager loading, or no complexity-prone params? no problem!
76
+ return nil unless does_eager_loading && has_complexity_prone_params
77
+
78
+ gather_includes(arglist.last[:include]).each do |inc|
79
+ plural = inc.to_s
80
+ singular = plural.singularize
81
+ class_name = singular.camelize
82
+ type = self.context.models.detect { |m| m.modeled_class_name == class_name }
83
+ # If we're eager loading a 1:1 association, don't bother to scream; it's likely
84
+ # that use the user would want to load the deleted thing anyway.
85
+ # TODO replace this clever hack, which infers a :has_many association using plurality
86
+ if !type || ( type.paranoid? && (plural != singular) )
87
+ return type
88
+ end
89
+ end
90
+
91
+ return nil
92
+ end
93
+
94
+ def gather_includes(data)
95
+ out = []
96
+ case data
97
+ when Array
98
+ data.each { |elem| out += gather_includes(elem) }
99
+ when Hash
100
+ data.keys.each { |elem| out += gather_includes(elem) }
101
+ data.values.each { |elem| out += gather_includes(elem) }
102
+ when Symbol
103
+ out << data
104
+ end
105
+
106
+ return out
107
+ end
108
+ end
109
+ end
110
+ end