msftidy_docs.rb 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. #!/usr/bin/env ruby
  2. # -*- coding: binary -*-
  3. #
  4. # Check (recursively) for style compliance violations and other
  5. # tree inconsistencies.
  6. #
  7. # by h00die
  8. #
  9. require 'fileutils'
  10. require 'find'
  11. require 'time'
  12. SUPPRESS_INFO_MESSAGES = !!ENV['MSF_SUPPRESS_INFO_MESSAGES']
  13. class String
  14. def red
  15. "\e[1;31;40m#{self}\e[0m"
  16. end
  17. def yellow
  18. "\e[1;33;40m#{self}\e[0m"
  19. end
  20. def green
  21. "\e[1;32;40m#{self}\e[0m"
  22. end
  23. def cyan
  24. "\e[1;36;40m#{self}\e[0m"
  25. end
  26. end
  27. class MsftidyDoc
  28. # Status codes
  29. OK = 0
  30. WARNING = 1
  31. ERROR = 2
  32. # Some compiles regexes
  33. REGEX_MSF_EXPLOIT = / \< Msf::Exploit/
  34. REGEX_IS_BLANK_OR_END = /^\s*end\s*$/
  35. attr_reader :full_filepath, :source, :stat, :name, :status
  36. def initialize(source_file)
  37. @full_filepath = source_file
  38. @module_type = File.dirname(File.expand_path(@full_filepath))[/\/modules\/([^\/]+)/, 1]
  39. @source = load_file(source_file)
  40. @lines = @source.lines # returns an enumerator
  41. @status = OK
  42. @name = File.basename(source_file)
  43. end
  44. public
  45. #
  46. # Display a warning message, given some text and a number. Warnings
  47. # are usually style issues that may be okay for people who aren't core
  48. # Framework developers.
  49. #
  50. # @return status [Integer] Returns WARNINGS unless we already have an
  51. # error.
  52. def warn(txt, line=0) line_msg = (line>0) ? ":#{line}" : ''
  53. puts "#{@full_filepath}#{line_msg} - [#{'WARNING'.yellow}] #{cleanup_text(txt)}"
  54. @status = WARNING if @status < WARNING
  55. end
  56. #
  57. # Display an error message, given some text and a number. Errors
  58. # can break things or are so egregiously bad, style-wise, that they
  59. # really ought to be fixed.
  60. #
  61. # @return status [Integer] Returns ERRORS
  62. def error(txt, line=0)
  63. line_msg = (line>0) ? ":#{line}" : ''
  64. puts "#{@full_filepath}#{line_msg} - [#{'ERROR'.red}] #{cleanup_text(txt)}"
  65. @status = ERROR if @status < ERROR
  66. end
  67. # Currently unused, but some day msftidy will fix errors for you.
  68. def fixed(txt, line=0)
  69. line_msg = (line>0) ? ":#{line}" : ''
  70. puts "#{@full_filepath}#{line_msg} - [#{'FIXED'.green}] #{cleanup_text(txt)}"
  71. end
  72. #
  73. # Display an info message. Info messages do not alter the exit status.
  74. #
  75. def info(txt, line=0)
  76. return if SUPPRESS_INFO_MESSAGES
  77. line_msg = (line>0) ? ":#{line}" : ''
  78. puts "#{@full_filepath}#{line_msg} - [#{'INFO'.cyan}] #{cleanup_text(txt)}"
  79. end
  80. ##
  81. #
  82. # The functions below are actually the ones checking the source code
  83. #
  84. ##
  85. def has_module
  86. module_filepath = @full_filepath.sub('documentation/','').sub('/exploit/', '/exploits/')
  87. found = false
  88. ['.rb', '.py', '.go'].each do |ext|
  89. if File.file? module_filepath.sub(/.md$/, ext)
  90. found = true
  91. break
  92. end
  93. end
  94. unless found
  95. error("Doc missing module. Check file name and path(s) are correct. Doc: #{@full_filepath}")
  96. end
  97. end
  98. def check_start_with_vuln_app
  99. unless @lines.first =~ /^## Vulnerable Application$/
  100. warn('Docs should start with ## Vulnerable Application')
  101. end
  102. end
  103. def has_h2_headings
  104. has_vulnerable_application = false
  105. has_verification_steps = false
  106. has_scenarios = false
  107. has_options = false
  108. has_bad_description = false
  109. has_bad_intro = false
  110. has_bad_scenario_sub = false
  111. @lines.each do |line|
  112. if line =~ /^## Vulnerable Application$/
  113. has_vulnerable_application = true
  114. next
  115. end
  116. if line =~ /^## Verification Steps$/ || line =~ /^## Module usage$/
  117. has_verification_steps = true
  118. next
  119. end
  120. if line =~ /^## Scenarios$/
  121. has_scenarios = true
  122. next
  123. end
  124. if line =~ /^## Options$/
  125. has_options = true
  126. next
  127. end
  128. if line =~ /^## Description$/
  129. has_bad_description = true
  130. next
  131. end
  132. if line =~ /^## (Intro|Introduction)$/
  133. has_bad_intro = true
  134. next
  135. end
  136. if line =~ /### Version and OS$/
  137. has_bad_scenario_sub = true
  138. next
  139. end
  140. end
  141. unless has_vulnerable_application
  142. warn('Missing Section: ## Vulnerable Application')
  143. end
  144. unless has_verification_steps
  145. warn('Missing Section: ## Verification Steps')
  146. end
  147. unless has_scenarios
  148. warn('Missing Section: ## Scenarios')
  149. end
  150. unless has_options
  151. # INFO because there may be no documentation-worthy options
  152. info('Missing Section: ## Options')
  153. end
  154. if has_bad_description
  155. warn('Descriptions should be within Vulnerable Application, or an H3 sub-section of Vulnerable Application')
  156. end
  157. if has_bad_intro
  158. warn('Intro/Introduction should be within Vulnerable Application, or an H3 sub-section of Vulnerable Application')
  159. end
  160. if has_bad_scenario_sub
  161. warn('Scenario sub-sections should include the vulnerable application version and OS tested on in an H3, not just ### Version and OS')
  162. end
  163. end
  164. def check_newline_eof
  165. if @source !~ /(?:\r\n|\n)\z/m
  166. warn('Please add a newline at the end of the file')
  167. end
  168. end
  169. # This checks that the H2 headings are in the right order. Options are optional.
  170. def h2_order
  171. unless @source =~ /^## Vulnerable Application$.+^## (Verification Steps|Module usage)$.+(?:^## Options$.+)?^## Scenarios$/m
  172. warn('H2 headings in incorrect order. Should be: Vulnerable Application, Verification Steps/Module usage, Options, Scenarios')
  173. end
  174. end
  175. def line_checks
  176. idx = 0
  177. in_codeblock = false
  178. in_options = false
  179. @lines.each do |ln|
  180. idx += 1
  181. tback = ln.scan(/```/)
  182. if tback.length > 0
  183. if tback.length.even?
  184. warn("Should use single backquotes (`) for single line literals instead of triple backquotes (```)", idx)
  185. else
  186. in_codeblock = !in_codeblock
  187. end
  188. if ln =~ /^\s+```/
  189. warn("Code blocks using triple backquotes (```) should not be indented", idx)
  190. end
  191. end
  192. if ln =~ /## Options/
  193. in_options = true
  194. end
  195. if ln =~ /## Scenarios/ || (in_options && ln =~ /$\s*## /) # we're not in options anymore
  196. # we set a hard false here because there isn't a guarantee options exists
  197. in_options = false
  198. end
  199. if in_options && ln =~ /^\s*\*\*[a-z]+\*\*$/i # catch options in old format like **command** instead of ### command
  200. warn("Options should use ### instead of bolds (**)", idx)
  201. end
  202. # this will catch either bold or h2/3 universal options. Defaults aren't needed since they're not unique to this exploit
  203. if in_options && ln =~ /^\s*[\*#]{2,3}\s*(rhost|rhosts|rport|lport|lhost|srvhost|srvport|ssl|uripath|session|proxies|payload|targeturi)\*{0,2}$/i
  204. warn('Universal options such as rhost(s), rport, lport, lhost, srvhost, srvport, ssl, uripath, session, proxies, payload, targeturi can be removed.', idx)
  205. end
  206. # find spaces at EOL not in a code block which is ``` or starts with four spaces
  207. if !in_codeblock && ln =~ /[ \t]$/ && !(ln =~ /^ /)
  208. warn("Spaces at EOL", idx)
  209. end
  210. if ln =~ /Example steps in this format/
  211. warn("Instructional text not removed", idx)
  212. end
  213. if ln =~ /^# /
  214. warn("No H1 (#) headers. If this is code, indent.", idx)
  215. end
  216. l = 140
  217. if ln.rstrip.length > l && !in_codeblock
  218. warn("Line too long (#{ln.length}). Consider a newline (which resolves to a space in markdown) to break it up around #{l} characters.", idx)
  219. end
  220. end
  221. end
  222. #
  223. # Run all the msftidy checks.
  224. #
  225. def run_checks
  226. has_module
  227. check_start_with_vuln_app
  228. has_h2_headings
  229. check_newline_eof
  230. h2_order
  231. line_checks
  232. end
  233. private
  234. def load_file(file)
  235. f = open(file, 'rb')
  236. @stat = f.stat
  237. buf = f.read(@stat.size)
  238. f.close
  239. return buf
  240. end
  241. def cleanup_text(txt)
  242. # remove line breaks
  243. txt = txt.gsub(/[\r\n]/, ' ')
  244. # replace multiple spaces by one space
  245. txt.gsub(/\s{2,}/, ' ')
  246. end
  247. end
  248. ##
  249. #
  250. # Main program
  251. #
  252. ##
  253. if __FILE__ == $PROGRAM_NAME
  254. dirs = ARGV
  255. @exit_status = 0
  256. if dirs.length < 1
  257. $stderr.puts "Usage: #{File.basename(__FILE__)} <directory or file>"
  258. @exit_status = 1
  259. exit(@exit_status)
  260. end
  261. dirs.each do |dir|
  262. begin
  263. Find.find(dir) do |full_filepath|
  264. next if full_filepath =~ /\.git[\x5c\x2f]/
  265. next unless File.file? full_filepath
  266. next unless File.extname(full_filepath) == '.md'
  267. msftidy = MsftidyDoc.new(full_filepath)
  268. # Executable files are now assumed to be external modules
  269. # but also check for some content to be sure
  270. next if File.executable?(full_filepath) && msftidy.source =~ /require ["']metasploit["']/
  271. msftidy.run_checks
  272. @exit_status = msftidy.status if (msftidy.status > @exit_status.to_i)
  273. end
  274. rescue Errno::ENOENT
  275. $stderr.puts "#{File.basename(__FILE__)}: #{dir}: No such file or directory"
  276. end
  277. end
  278. exit(@exit_status.to_i)
  279. end