#!/usr/bin/env ruby # -*- coding: binary -*- # # Check (recursively) for style compliance violations and other # tree inconsistencies. # # by h00die # require 'fileutils' require 'find' require 'time' SUPPRESS_INFO_MESSAGES = !!ENV['MSF_SUPPRESS_INFO_MESSAGES'] class String def red "\e[1;31;40m#{self}\e[0m" end def yellow "\e[1;33;40m#{self}\e[0m" end def green "\e[1;32;40m#{self}\e[0m" end def cyan "\e[1;36;40m#{self}\e[0m" end end class MsftidyDoc # Status codes OK = 0 WARNING = 1 ERROR = 2 # Some compiles regexes REGEX_MSF_EXPLOIT = / \< Msf::Exploit/ REGEX_IS_BLANK_OR_END = /^\s*end\s*$/ attr_reader :full_filepath, :source, :stat, :name, :status def initialize(source_file) @full_filepath = source_file @module_type = File.dirname(File.expand_path(@full_filepath))[/\/modules\/([^\/]+)/, 1] @source = load_file(source_file) @lines = @source.lines # returns an enumerator @status = OK @name = File.basename(source_file) end public # # Display a warning message, given some text and a number. Warnings # are usually style issues that may be okay for people who aren't core # Framework developers. # # @return status [Integer] Returns WARNINGS unless we already have an # error. def warn(txt, line=0) line_msg = (line>0) ? ":#{line}" : '' puts "#{@full_filepath}#{line_msg} - [#{'WARNING'.yellow}] #{cleanup_text(txt)}" @status = WARNING if @status < WARNING end # # Display an error message, given some text and a number. Errors # can break things or are so egregiously bad, style-wise, that they # really ought to be fixed. # # @return status [Integer] Returns ERRORS def error(txt, line=0) line_msg = (line>0) ? ":#{line}" : '' puts "#{@full_filepath}#{line_msg} - [#{'ERROR'.red}] #{cleanup_text(txt)}" @status = ERROR if @status < ERROR end # Currently unused, but some day msftidy will fix errors for you. def fixed(txt, line=0) line_msg = (line>0) ? ":#{line}" : '' puts "#{@full_filepath}#{line_msg} - [#{'FIXED'.green}] #{cleanup_text(txt)}" end # # Display an info message. Info messages do not alter the exit status. # def info(txt, line=0) return if SUPPRESS_INFO_MESSAGES line_msg = (line>0) ? ":#{line}" : '' puts "#{@full_filepath}#{line_msg} - [#{'INFO'.cyan}] #{cleanup_text(txt)}" end ## # # The functions below are actually the ones checking the source code # ## def has_module module_filepath = @full_filepath.sub('documentation/','').sub('/exploit/', '/exploits/') found = false ['.rb', '.py', '.go'].each do |ext| if File.file? module_filepath.sub(/.md$/, ext) found = true break end end unless found error("Doc missing module. Check file name and path(s) are correct. Doc: #{@full_filepath}") end end def check_start_with_vuln_app unless @lines.first =~ /^## Vulnerable Application$/ warn('Docs should start with ## Vulnerable Application') end end def has_h2_headings has_vulnerable_application = false has_verification_steps = false has_scenarios = false has_options = false has_bad_description = false has_bad_intro = false has_bad_scenario_sub = false @lines.each do |line| if line =~ /^## Vulnerable Application$/ has_vulnerable_application = true next end if line =~ /^## Verification Steps$/ || line =~ /^## Module usage$/ has_verification_steps = true next end if line =~ /^## Scenarios$/ has_scenarios = true next end if line =~ /^## Options$/ has_options = true next end if line =~ /^## Description$/ has_bad_description = true next end if line =~ /^## (Intro|Introduction)$/ has_bad_intro = true next end if line =~ /### Version and OS$/ has_bad_scenario_sub = true next end end unless has_vulnerable_application warn('Missing Section: ## Vulnerable Application') end unless has_verification_steps warn('Missing Section: ## Verification Steps') end unless has_scenarios warn('Missing Section: ## Scenarios') end unless has_options # INFO because there may be no documentation-worthy options info('Missing Section: ## Options') end if has_bad_description warn('Descriptions should be within Vulnerable Application, or an H3 sub-section of Vulnerable Application') end if has_bad_intro warn('Intro/Introduction should be within Vulnerable Application, or an H3 sub-section of Vulnerable Application') end if has_bad_scenario_sub warn('Scenario sub-sections should include the vulnerable application version and OS tested on in an H3, not just ### Version and OS') end end def check_newline_eof if @source !~ /(?:\r\n|\n)\z/m warn('Please add a newline at the end of the file') end end # This checks that the H2 headings are in the right order. Options are optional. def h2_order unless @source =~ /^## Vulnerable Application$.+^## (Verification Steps|Module usage)$.+(?:^## Options$.+)?^## Scenarios$/m warn('H2 headings in incorrect order. Should be: Vulnerable Application, Verification Steps/Module usage, Options, Scenarios') end end def line_checks idx = 0 in_codeblock = false in_options = false @lines.each do |ln| idx += 1 tback = ln.scan('```') if tback.length > 0 if tback.length.even? warn("Should use single backquotes (`) for single line literals instead of triple backquotes (```)", idx) else in_codeblock = !in_codeblock end if ln =~ /^\s+```/ warn("Code blocks using triple backquotes (```) should not be indented", idx) end end if ln =~ /## Options/ in_options = true end if ln =~ /## Scenarios/ || (in_options && ln =~ /$\s*## /) # we're not in options anymore # we set a hard false here because there isn't a guarantee options exists in_options = false end if in_options && ln =~ /^\s*\*\*[a-z]+\*\*$/i # catch options in old format like **command** instead of ### command warn("Options should use ### instead of bolds (**)", idx) end # this will catch either bold or h2/3 universal options. Defaults aren't needed since they're not unique to this exploit if in_options && ln =~ /^\s*[\*#]{2,3}\s*(rhost|rhosts|rport|lport|lhost|srvhost|srvport|ssl|uripath|session|proxies|payload|targeturi)\*{0,2}$/i warn('Universal options such as rhost(s), rport, lport, lhost, srvhost, srvport, ssl, uripath, session, proxies, payload, targeturi can be removed.', idx) end # find spaces at EOL not in a code block which is ``` or starts with four spaces if !in_codeblock && ln =~ /[ \t]$/ && !(ln =~ /^ /) warn("Spaces at EOL", idx) end if ln =~ /Example steps in this format/ warn("Instructional text not removed", idx) end if ln =~ /^# / warn("No H1 (#) headers. If this is code, indent.", idx) end l = 140 if ln.rstrip.length > l && !in_codeblock warn("Line too long (#{ln.length}). Consider a newline (which resolves to a space in markdown) to break it up around #{l} characters.", idx) end end end # # Run all the msftidy checks. # def run_checks has_module check_start_with_vuln_app has_h2_headings check_newline_eof h2_order line_checks end private def load_file(file) f = open(file, 'rb') @stat = f.stat buf = f.read(@stat.size) f.close return buf end def cleanup_text(txt) # remove line breaks txt = txt.gsub(/[\r\n]/, ' ') # replace multiple spaces by one space txt.gsub(/\s{2,}/, ' ') end end ## # # Main program # ## if __FILE__ == $PROGRAM_NAME dirs = ARGV @exit_status = 0 if dirs.length < 1 $stderr.puts "Usage: #{File.basename(__FILE__)} " @exit_status = 1 exit(@exit_status) end dirs.each do |dir| begin Find.find(dir) do |full_filepath| next if full_filepath =~ /\.git[\x5c\x2f]/ next unless File.file? full_filepath next unless File.extname(full_filepath) == '.md' msftidy = MsftidyDoc.new(full_filepath) # Executable files are now assumed to be external modules # but also check for some content to be sure next if File.executable?(full_filepath) && msftidy.source =~ /require ["']metasploit["']/ msftidy.run_checks @exit_status = msftidy.status if (msftidy.status > @exit_status.to_i) end rescue Errno::ENOENT $stderr.puts "#{File.basename(__FILE__)}: #{dir}: No such file or directory" end end exit(@exit_status.to_i) end