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