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