1desc "Generate security audit and code quality report"
 
2# e.g.: rake code_quality lowest_score=90 max_offenses=100 metrics=stats,rails_best_practices,roodi rails_best_practices_max_offenses=10 roodi_max_offenses=10
 
3task :code_quality => :"code_quality:default" do; end if Rake.application.instance_of?(Rake::Application)
 
4namespace :code_quality do
 
5  task :default => [:summary, :security_audit, :quality_audit, :generate_index] do; end
 
6
 
7  # desc "show summary"
 
8  task :summary do
 
 9    puts "# Code Quality Report", "\n"
 
10    puts "Generated by code_quality (v#{CodeQuality::VERSION}) @ #{Time.now}", "\n"
 
11  end
 
 
13  # desc "generate a report index page"
 
14  task :generate_index => :helpers do
 
15    index_path = "tmp/code_quality/index.html"
 
16    generate_index index_path
 
17    # puts "Generate report index to #{index_path}"
 
18    show_in_browser File.realpath(index_path)
 
19  end
 
 
21  desc "security audit using bundler-audit, brakeman"
 
22  task :security_audit => [:"security_audit:default"] do; end
 
23  namespace :security_audit do
 
24    # default tasks
 
25    task :default => [:bundler_audit, :brakeman, :resources] do; end
 
 
27    # desc "prepare dir"
 
28    task :prepare => :helpers do
 
29      @report_dir = "tmp/code_quality/security_audit"
 
30      prepare_dir @report_dir
 
 
32      def report_dir
 
33        @report_dir
 
34      end
 
35    end
 
 
37    desc "bundler audit"
 
38    task :bundler_audit => :prepare do |task|
 
39      run_audit task, "bundler audit - checks for vulnerable versions of gems in Gemfile.lock" do
 
40        # Update the ruby-advisory-db and check Gemfile.lock
 
41        report = `bundle audit check --update`
 
42        @report_path = "#{report_dir}/bundler-audit-report.txt"
 
43        File.open(@report_path, 'w') {|f| f.write report }
 
44        puts report
 
45        audit_faild "Must fix vulnerabilities ASAP" unless report =~ /No vulnerabilities found/
 
46      end
 
47    end
 
 
49    desc "brakeman"
 
50    # options:
 
51    #   brakeman_options: pass extract CLI options, e.g.: brakeman_options="--skip-files lib/templates/"
 
52    task :brakeman => :prepare do |task|
 
53      options = options_from_env(:brakeman_options)
 
54      require 'json'
 
55      run_audit task, "Brakeman audit - checks Ruby on Rails applications for security vulnerabilities" do
 
56        @report_path = "#{report_dir}/brakeman-report.txt"
 
57        `brakeman -o #{@report_path} -o #{report_dir}/brakeman-report.json #{options[:brakeman_options]} .`
 
58        puts `cat #{@report_path}`
 
59        report = JSON.parse(File.read("#{report_dir}/brakeman-report.json"))
 
60        audit_faild "There are #{report["errors"].size} errors, must fix them ASAP." if report["errors"].any?
 
61      end
 
62    end
 
 
64    # desc "resources url"
 
65    task :resources do
 
66      refs = %w{
67
 
67        https://github.com/presidentbeef/brakeman
 
68        https://github.com/rubysec/bundler-audit
 
69        http://guides.rubyonrails.org/security.html
 
70        https://github.com/hardhatdigital/rails-security-audit
 
71        https://hakiri.io/blog/ruby-security-tools-and-resources
 
72        https://www.netsparker.com/blog/web-security/ruby-on-rails-security-basics/
 
73        https://www.owasp.org/index.php/Ruby_on_Rails_Cheatsheet
 
74      }
 
75      puts "## Security Resources"
 
76      puts refs.map { |url| "  - #{url}" }, "\n"
 
77    end
 
78  end
 
 
80  desc "code quality audit"
 
81  # e.g.: rake code_quality:quality_audit fail_fast=true
 
82  # options:
 
83  #   fail_fast: to stop immediately if any audit task fails, by default fail_fast=false
 
84  #   generate_index: generate a report index page to tmp/code_quality/quality_audit/index.html, by default generate_index=false
 
85  task :quality_audit => [:"quality_audit:default"] do; end
 
86  namespace :quality_audit do |ns|
 
87    # default tasks
 
88    task :default => [:run_all, :resources] do; end
 
 
90    # desc "run all audit tasks"
 
91    task :run_all => :helpers do
 
92      options = options_from_env(:fail_fast, :generate_index)
 
93      fail_fast = options.fetch(:fail_fast, "false")
 
94      generate_index = options.fetch(:generate_index, "false")
 
95      audit_tasks = [:rubycritic, :rubocop, :metric_fu]
 
96      exc = nil
 
97      audit_tasks.each do |task_name|
 
98        begin
 
 99          task = ns[task_name]
 
100          task.invoke
 
101        rescue SystemExit => exc
 
102          raise exc if fail_fast == "true"
 
103        end
 
104      end
 
 
106      # generate a report index page to tmp/code_quality/quality_audit/index.html
 
107      if options[:generate_index] == "true"
 
108        index_path = "tmp/code_quality/quality_audit/index.html"
 
109        @audit_tasks.each do |task_name, report|
 
110          report[:report_path].sub!("quality_audit/", "")
 
111        end
 
112        generate_index index_path
 
113        puts "Generate report index to #{index_path}"
 
114      end
 
 
116      audit_faild "" if exc
 
117    end
 
 
119    # desc "prepare dir"
 
120    task :prepare => :helpers do
 
121      @report_dir = "tmp/code_quality/quality_audit"
 
122      prepare_dir @report_dir
 
 
124      def report_dir
 
125        @report_dir
 
126      end
 
127    end
 
 
129    desc "rubycritic"
 
130    # e.g.: rake code_quality:quality_audit:rubycritic lowest_score=94.5
 
131    task :rubycritic => :prepare do |task|
 
132      options = options_from_env(:lowest_score)
 
133      run_audit task, "Rubycritic - static analysis gems such as Reek, Flay and Flog to provide a quality report of your Ruby code." do
 
134        report = `rubycritic -p #{report_dir}/rubycritic app lib --no-browser`
 
135        puts report
 
136        @report_path = report_path = "#{report_dir}/rubycritic/overview.html"
 
137        show_in_browser File.realpath(report_path)
 
 
139        # if config lowest_score then audit it with report score
 
140        if options[:lowest_score]
 
141          if report[-20..-1] =~ /Score: (.+)/
 
142            report_score = $1.to_f
 
143            lowest_score = options[:lowest_score].to_f
 
144            audit_faild "Report score #{colorize(report_score, :yellow)} is lower then #{colorize(lowest_score, :yellow)}, must improve your code quality or set a higher #{colorize("lowest_score", :black, :white)}" if report_score < lowest_score
 
145          end
 
146        end
 
147      end
 
148    end
 
 
150    desc "rubocop - audit coding style"
 
151    # e.g.: rake code_quality:quality_audit:rubocop rubocop_max_offenses=100
 
152    # options:
 
153    #   config_formula: use which formula for config, supports "github, "rails" or path_to_your_local_config.yml, default is "github"
 
154    #   cli_options: pass extract options, e.g.: cli_options="--show-cops"
 
155    #   rubocop_max_offenses: if config rubocop_max_offenses then audit it with detected offenses number in report, e.g.: rubocop_max_offenses=100
 
156    task :rubocop => :prepare do |task|
 
157      run_audit task, "rubocop - RuboCop is a Ruby static code analyzer. Out of the box it will enforce many of the guidelines outlined in the community Ruby Style Guide." do
 
158        options = options_from_env(:config_formula, :cli_options, :rubocop_max_offenses)
 
 
160        config_formulas = {
 
161          'github' => 'https://github.com/github/rubocop-github',
 
162          'rails' => 'https://github.com/rails/rails/blob/master/.rubocop.yml'
 
163        }
 
 
165        # prepare cli options
 
166        config_formula = options.fetch(:config_formula, 'github')
 
167        if config_formula && File.exists?(config_formula)
 
168          config_file = config_formula
 
169          puts "Using config file: #{config_file}"
 
170        else
 
171          gem_config_dir = File.expand_path("../../../config", __FILE__)
 
172          config_file    = "#{gem_config_dir}/rubocop-#{config_formula}.yml"
 
173          puts "Using config formula: [#{config_formula}](#{config_formulas[config_formula]})"
 
174        end
 
175        @report_path = report_path = "#{report_dir}/rubocop-report.html"
 
 
177        # generate report
 
178        report = `rubocop -c #{config_file} -S -R -P #{options[:cli_options]} --format offenses --format html -o #{report_path}`
 
179        puts report
 
180        puts "Report generated to #{report_path}"
 
181        show_in_browser File.realpath(report_path)
 
 
183        # if config rubocop_max_offenses then audit it with detected offenses number in report
 
184        if options[:rubocop_max_offenses]
 
185          if report[-20..-1] =~ /(\d+) *Total/
 
186            detected_offenses = $1.to_i
 
187            max_offenses = options[:rubocop_max_offenses].to_i
 
188            audit_faild "Detected offenses #{colorize(detected_offenses, :yellow)} is more then #{colorize(max_offenses, :yellow)}, must improve your code quality or set a lower #{colorize("rubocop_max_offenses", :black, :white)}" if detected_offenses > max_offenses
 
189          end
 
190        end
 
191      end
 
192    end
 
 
194    desc "metric_fu - many kinds of metrics"
 
195    # e.g.: rake code_quality:quality_audit:metric_fu metrics=stats,rails_best_practices,roodi rails_best_practices_max_offenses=9 roodi_max_offenses=10
 
196    # options:
 
197    #   metrics: default to run all metrics, can be config as: cane,churn,flay,flog,hotspots,rails_best_practices,rcov,reek,roodi,saikuro,stats
 
198    #   flay_max_offenses: offenses number for audit
 
199    #   cane_max_offenses: offenses number for audit
 
200    #   rails_best_practices_max_offenses: offenses number for audit
 
201    #   reek_max_offenses: offenses number for audit
 
202    #   roodi_max_offenses: offenses number for audit
 
203    task :metric_fu => :prepare do |task|
 
204      metrics_offenses_patterns = {
 
205        "flay" => /Total Score (\d+)/,
 
206        "cane" => /Total Violations (\d+)/,
 
207        "rails_best_practices" => /Found (\d+) errors/,
 
208        "reek" => /Found (\d+) code smells/,
 
209        "roodi" => /Found (\d+) errors/,
 
210      }
 
211      metrics_have_offenses = metrics_offenses_patterns.keys.map { |metric| "#{metric}_max_offenses".to_sym }
 
212      options = options_from_env(:metrics, *metrics_have_offenses)
 
213      run_audit task, "metric_fu - Code metrics from Flog, Flay, Saikuro, Churn, Reek, Roodi, Code Statistics, and Rails Best Practices. (and optionally RCov)" do
 
214        report_path = "#{report_dir}/metric_fu"
 
215        available_metrics = %w{cane churn flay flog hotspots rails_best_practices rcov reek roodi saikuro stats}
 
216        metric_fu_opts = ""
 
217        selected_metrics = available_metrics
 
218        if options[:metrics]
 
219          selected_metrics = options[:metrics].split(",")
 
220          disable_metrics = available_metrics - selected_metrics
 
221          selected_metrics_opt = selected_metrics.map { |m| "--#{m}" }.join(" ")
 
222          disable_metrics_opt = disable_metrics.map { |m| "--no-#{m}" }.join(" ")
 
223          metric_fu_opts = "#{selected_metrics_opt} #{disable_metrics_opt}"
 
224          puts "for metrics: #{selected_metrics.join(",")}"
 
225        end
 
226        # geneate report
 
227        report = `metric_fu --no-open #{metric_fu_opts}`
 
228        FileUtils.remove_dir(report_path) if Dir.exists? report_path
 
229        FileUtils.mv("tmp/metric_fu/output", report_path, force: true)
 
230        puts report
 
231        puts "Report generated to #{report_path}"
 
232        show_in_browser File.realpath(report_path)
 
233        @report_path = "#{report_path}/index.html"
 
 
235        # audit report result
 
236        report_result_path = "tmp/metric_fu/report.yml"
 
237        if File.exists? report_result_path
 
238          require 'yaml'
 
239          report_result = YAML.load_file(report_result_path)
 
240          # if config #{metric}_max_offenses then audit it with report result
 
241          audit_failures = []
 
242          metrics_offenses_patterns.each do |metric, pattern|
 
243            option_key = "#{metric}_max_offenses".to_sym
 
244            if options[option_key]
 
245              detected_offenses = report_result[metric.to_sym][:total].to_s.match(pattern)[1].to_i rescue 0
 
246              max_offenses = options[option_key].to_i
 
247              if detected_offenses > max_offenses
 
248                puts "Metric #{colorize(metric, :green)} detected offenses #{colorize(detected_offenses, :yellow)} is more then #{colorize(max_offenses, :yellow)}, must improve your code quality or set a lower #{colorize(option_key, :black, :white)}"
 
249                audit_failures << {metric: metric, detected_offenses: detected_offenses, max_offenses: max_offenses}
 
250              end
 
251            end
 
252          end
 
253          audit_faild "#{audit_failures.size} of #{selected_metrics.size} metrics audit failed" if audit_failures.any?
 
254        end
 
255      end
 
256    end
 
 
258    # desc "resources url"
 
259    task :resources do
 
260      refs = %w{
261
 
261        http://awesome-ruby.com/#-code-analysis-and-metrics
 
262        https://github.com/whitesmith/rubycritic
 
263        https://github.com/bbatsov/rubocop
 
264        https://github.com/bbatsov/ruby-style-guide
 
265        https://github.com/github/rubocop-github
 
266        https://github.com/metricfu/metric_fu
 
267        https://rails-bestpractices.com
 
268      }
 
269      puts "## Code Quality Resources"
 
270      puts refs.map { |url| "  - #{url}" }
 
271    end
 
272  end
 
 
274  # desc "helper methods"
 
275  task :helpers do
 
276    def run_audit(task, title, &block)
 
277      task_name = task.name.split(":").last
 
278      @audit_tasks ||= {}
 
279      @audit_tasks[task_name] ||= {
 
280        report_path: "",
 
281        failure: "",
 
282      }
 
283      puts "## #{title}"
 
284      puts "", "```"
 
285      exc = nil
 
286      begin
 
287        realtime(&block)
 
288      rescue SystemExit => exc
 
289        # audit faild
 
290        @audit_tasks[task_name][:failure] = exc.message.gsub(/(\e\[\d+m)/, "")
 
291      ensure
 
292        # get @report_path set in each audit task
 
293        @audit_tasks[task_name][:report_path] = @report_path&.sub("tmp/code_quality/", "")
 
294      end
 
295      puts "```", ""
 
296      raise exc if exc
 
297    end
 
 
299    def realtime(&block)
 
300      require 'benchmark'
 
301      realtime = Benchmark.realtime do
 
302        block.call
 
303      end.round
 
304      process_time = humanize_secs(realtime)
 
305      puts "[ #{process_time} ]"
 
306    end
 
 
308    # p humanize_secs 60
 
309    # => 1m
 
310    # p humanize_secs 1234
 
311    #=>"20m 34s"
 
312    def humanize_secs(secs)
 
313      [[60, :s], [60, :m], [24, :h], [1000, :d]].map{ |count, name|
 
314        if secs > 0
 
315          secs, n = secs.divmod(count)
 
316          "#{n.to_i}#{name}"
 
317        end
 
318      }.compact.reverse.join(' ').chomp(' 0s')
 
319    end
 
 
321    def prepare_dir(dir)
 
322      FileUtils.mkdir_p dir
 
323    end
 
 
325    def audit_faild(msg)
 
326      flag = colorize("[AUDIT FAILED]", :red, :yellow)
 
327      abort "#{flag} #{msg}"
 
328    end
 
 
330    # e.g.: options_from_env(:a, :b) => {:a => ..., :b => ... }
 
331    def options_from_env(*keys)
 
332      # ENV.to_h.slice(*keys.map(&:to_s)).symbolize_keys! # using ActiveSupport
 
333      ENV.to_h.inject({}) { |opts, (k, v)| keys.include?(k.to_sym) ? opts.merge({k.to_sym => v}) : opts }
 
334    end
 
 
336    # set text color, background color using ANSI escape sequences, e.g.:
 
337    #   colors = %w(black red green yellow blue pink cyan white default)
 
338    #   colors.each { |color| puts colorize(color, color) }
 
339    #   colors.each { |color| puts colorize(color, :green, color) }
 
340    def colorize(text, color = "default", bg = "default")
 
341      colors = %w(black red green yellow blue pink cyan white default)
 
342      fgcode = 30; bgcode = 40
 
343      tpl = "\e[%{code}m%{text}\e[0m"
 
344      cov = lambda { |txt, col, cod| tpl % {text: txt, code: (cod+colors.index(col.to_s))} }
 
345      ansi = cov.call(text, color, fgcode)
 
346      ansi = cov.call(ansi, bg, bgcode) if bg.to_s != "default"
 
347      ansi
 
348    end
 
 
350    def show_in_browser(dir)
 
351      require "launchy"
 
352      require "uri"
 
353      uri = URI.escape("file://#{dir}/")
 
354      if File.directory?(dir)
 
355        uri = URI.join(uri, "index.html")
 
356      end
 
357      Launchy.open(uri) if open_in_browser?
 
358    end
 
 
360    def open_in_browser?
 
361      ENV["CI"].nil?
 
362    end
 
 
364    def generate_index(index_path)
 
365      require "erb"
 
366      prepare_dir "tmp/code_quality"
 
367      gem_app_dir = File.expand_path("../../../app", __FILE__)
 
368      erb_file = "#{gem_app_dir}/views/code_quality/index.html.erb"
 
 
370      # render view
 
371      @audit_tasks ||= []
 
372      erb = ERB.new(File.read(erb_file))
 
373      output = erb.result(binding)
 
 
375      File.open(index_path, 'w') {|f| f.write output }
 
376    end
 
377  end
 
 
379end