rapid7/metasploit-framework

View on GitHub
tools/dev/report_generation/support_matrix/generate.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

$LOAD_PATH.unshift(File.join(__dir__, '..', '..', '..', '..', 'spec'))
$LOAD_PATH.unshift(File.join(__dir__, '..', '..', '..', '..', 'lib'))

require 'active_support'
require 'active_support/core_ext'
require 'allure_config'
require 'json'
require 'erb'
require 'optparse'
require 'msfenv'
require 'rex'
require 'rex/post'

module ReportGeneration
  class SupportMatrix
    def initialize(data)
      @data = data
    end

    def generation_date
      @generation_date ||= Time.now.strftime('%FT%T')
    end

    def all_commands
      Rex::Post::Meterpreter::CommandMapper.get_command_names
    end

    def table
      sorted_sessions = @data.fetch(:sessions, []).sort_by { |session| session[:session_type] }

      # Group into buckets, and prioritize sort order
      extension_names = [
        # 'Required' Meterpreter extensions
        'core',
        'stdapi',

        'sniffer',
        'extapi',
        'kiwi',
        'python',
        'unhook',
        'appapi',
        'winpmem',
        'powershell',
        'lanattacks',
        'priv',
        'incognito',
        'peinjector',
        'espia',
        'android',

        # any missing new/missing extensions will added to the end lexicographically
      ]

      # Add any new extension names that aren't currently known about
      extension_names += all_commands.each_with_object([]) do |command, unknown_extensions|
        command_prefix = command.split('_').first
        next if extension_names.include?(command_prefix)

        unknown_extensions << command_prefix
      end.sort

      ordered_commands = all_commands.sort_by do |command|
        command_prefix = command.split('_').first
        sort_index = extension_names.index(command_prefix)
        sort_index
      end

      # Map session type to supported commands. i.e. { osx: { command_name_1: true } }
      sessions_to_supported_commands_hash = sorted_sessions.each_with_object({}) do |session, hash|
        session_type = session[:session_type]
        # Map command name to its availability
        supported_command_map = session[:commands].each_with_object({}) do |command, map|
          command_name = command[:name]
          map[command_name] = true
        end
        hash[session_type] = supported_command_map
      end

      columns = [{ heading: '' }] + sorted_sessions.map do |session|
        { heading: session[:session_type], metadata: session[:metadata] }
      end

      rows = extension_names.map do |extension_name|
        extension_commands = ordered_commands.select { |command| command.start_with?(extension_name) }

        command_rows = extension_commands.map do |command|
          session_supported_cells = sessions_to_supported_commands_hash.map do |(_session, compatibility)|
            compatibility.include?(command)
          end

          [command] + session_supported_cells
        end
        extension_coverage = sessions_to_supported_commands_hash.map do |(_session, compatibility)|
          implemented_count = extension_commands.select { |command| compatibility.include?(command) }.size
          total_count = extension_commands.size
          percentage = ((implemented_count.to_f / total_count) * 100).to_i

          "#{percentage}%"
        end

        {
          heading: [extension_name] + extension_coverage,
          values: command_rows
        }
      end

      {
        columns: columns,
        rows: rows
      }
    end

    def get_binding
      binding
    end
  end

  def self.extract_data(options)
    if options[:allure_data]
      results_directory = options[:allure_data]

      test_result_files = Dir['**/*-result.json', base: results_directory]
      meterpreter_compatibility_results = test_result_files.filter_map do |test_result_file|
        path = File.join(results_directory, test_result_file)
        test_result_json = JSON.parse(File.read(path), symbolize_names: true)

        compatibility_attachment = test_result_json.fetch(:attachments, [])
                                       .find { |attachment| attachment[:name] == 'available commands' }
        next unless compatibility_attachment

        compatibility_attachment_path = File.join(File.dirname(path), compatibility_attachment[:source])
        compatibility_json = JSON.parse(File.read(compatibility_attachment_path), symbolize_names: true)
        compatibility_json[:sessions].each do |session|
          session[:metadata] = test_result_json[:parameters].each_with_object({}) do |param, acc|
            acc[param[:name]] = param[:value]
          end
        end

        compatibility_json
      end

      sessions = meterpreter_compatibility_results.flat_map { |results| results[:sessions] }
      sorted_sessions = sessions.sort_by do |session|
        [session[:session_type], session[:metadata]['host_runner_image'], session[:metadata]['meterpreter_runtime_version'].to_s]
      end

      unique_sessions = sorted_sessions.each_with_object({}) do |session, acc|
        acc[session[:session_type]] = session
      end.values

      aggregated_data = {
        sessions: unique_sessions
      }

      aggregated_data
    else
      data_path = options.fetch(:data_path)
      JSON.parse(File.read(data_path), symbolize_names: true)
    end
  end

  def self.generate(options)
    data = extract_data(options)
    support_matrix = SupportMatrix.new(data)

    if options[:format] == :json
      $stdout.write JSON.pretty_generate(support_matrix.data)
    else
      template = File.read(File.join(File.dirname(__FILE__), 'template.erb'))
      renderer = ERB.new(template, trim_mode: '-')

      html = renderer.result(support_matrix.get_binding)
      $stdout.write(html)
    end
  end
end

if $PROGRAM_NAME == __FILE__
  options = {}
  options_parser = OptionParser.new do |opts|
    opts.banner = "Usage: #{File.basename(__FILE__)} [options]"

    opts.on '-h', '--help', 'Help banner.' do
      return print(opts.help)
    end

    opts.on('--allure-data path', 'Use allure as the data source') do |allure_data|
      allure_data ||= AllureRspec.configuration.results_directory
      options[:allure_data] = allure_data
    end

    opts.on('--data-path path',
            'The path to the report generated by scripts/resource/meterpreter_compatibility.rc') do |data_path|
      options[:data_path] = data_path
    end

    opts.on('--format value', %i[json html], 'Render in a given format') do |format|
      options[:format] = format
    end
  end
  options_parser.parse!

  ReportGeneration.generate(options)
end