postmodern/wordlist.rb

View on GitHub
lib/wordlist/cli.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true
require_relative 'file'
require_relative 'builder'
require_relative 'version'

require 'optparse'

module Wordlist
  #
  # Represents the `wordlist` command's logic.
  #
  # @api private
  #
  # @since 1.0.0
  #
  class CLI

    # The program name.
    PROGRAM_NAME = "wordlist"

    # The URL to report bugs to.
    BUG_REPORT_URL = "https://github.com/postmodern/wordlist.rb/issues/new"

    # Mapping of `--format` option values and `format:` Symbols.
    FORMATS = {
      'txt'  => :txt,
      'gzip' => :gzip,
      'bzip2'=> :bzip2,
      'xz'   => :xz,
      'zip'  => :zip,
      '7zip' => :"7zip"
    }

    # The command's option parser.
    #
    # @return [OptionParser]
    attr_reader :option_parser

    # Command mode (building or reading).
    #
    # @return [:build, :read]
    attr_reader :mode

    # The explicit wordlist format to use.
    #
    # @return [:txt, :gzip, :bzip2, :xz, nil]
    attr_reader :format

    # The path to the output wordlist file.
    #
    # @return [String, nil]
    attr_reader :output

    # The command to run with each word from the wordlist.
    #
    # @return [String, nil]
    attr_reader :command

    # Wordlist operators to apply.
    #
    # @return [Array<(Symbol, ...)>]
    attr_reader :operators

    # Wordlist modifiers to apply.
    #
    # @return [Array<(Symbol, ...)>]
    attr_reader :modifiers

    # Additional options for {Builder#initialize}.
    #
    # @return [Hash{Symbol => Object}]
    attr_reader :builder_options

    #
    # Initializes the command.
    #
    # @param [:read, :build] mode
    #
    # @param [:txt, :gzip, :bzip2, :xz, :zip, :7zip, nil] format
    #
    # @param [String, nil] command
    #
    def initialize(mode: :read, format: nil, command: nil)
      @option_parser = option_parser

      @mode    = mode
      @format  = format
      @command = command
      @output  = nil

      @operators = []
      @modifiers = []

      @builder_options = {}
    end

    #
    # Adds an operator to be applied to the wordlist(s) later.
    #
    # @param [Symbol] name
    #   The operator method name.
    #
    # @param [Array<Object>] args
    #   Additional arguments for the operator.
    #
    def add_operator(name,*args)
      @operators << [name, args]
    end

    #
    # Adds a modifier to be applied to the wordlist(s) later.
    #
    # @param [Symbol] name
    #   The modifier method name.
    #
    # @param [Array<Object>] args
    #   Additional arguments for the modifier.
    #
    def add_modifier(name,*args)
      @modifiers << [name, args]
    end

    #
    # Opens a wordlist file.
    #
    # @param [String] path
    #   The path to the wordlist file.
    #
    # @return [Wordlist::File]
    #   The opened wordlist.
    #
    def open_wordlist(path)
      if @format
        Wordlist::File.open(path, format: @format)
      else
        Wordlist::File.open(path)
      end
    rescue WordlistNotFound, UnknownFormat => error
      print_error(error.message)
      exit -1
    end

    #
    # Initializes and runs the command.
    #
    # @param [Array<String>] argv
    #   Command-line arguments.
    #
    # @return [Integer]
    #   The exit status of the command.
    #
    def self.run(argv=ARGV)
      new().run(argv)
    rescue Interrupt
      # https://tldp.org/LDP/abs/html/exitcodes.html
      return 130
    rescue Errno::EPIPE
      # STDOUT pipe broken
      return 0
    end

    #
    # Runs the command.
    #
    # @param [Array<String>] argv
    #   Command-line arguments.
    #
    # @return [Integer]
    #   The return status code.
    #
    def run(argv=ARGV)
      argv = begin
               @option_parser.parse(argv)
             rescue OptionParser::ParseError => error
               print_error(error.message)
               return -1
             end

      case @mode
      when :build then build_mode(argv)
      else             read_mode(argv)
      end
    rescue => error
      print_backtrace(error)
      return -1
    end

    #
    # Wordlist building mode.
    #
    # @param [Array<String>] argv
    #   Additional command-line arguments.
    #
    def build_mode(argv)
      builder = begin
                  if @format
                    Builder.open(@output, format: @format, **@builder_options)
                  else
                    Builder.open(@output, **@builder_options)
                  end
                rescue UnknownFormat, CommandNotFound => error
                  print_error(error.message)
                  return -1
                end

      begin
        if argv.empty?
          $stdin.each_line do |line|
            builder.parse(line)
          end
        else
          argv.each do |file|
            builder.parse_file(file)
          end
        end
      ensure
        builder.close
      end

      return 0
    end

    #
    # Wordlist reading mode.
    #
    # @param [Array<String>] argv
    #   Additional command-line arguments.
    #
    def read_mode(argv)
      unless argv.length >= 1
        print_error "too few arguments given, requires at least one WORDLIST argument"
        print_error "usage: #{PROGRAM_NAME} [options] WORDLIST ..."
        return -1
      end

      # open the first wodlist
      wordlist = open_wordlist(argv.first)

      # append the additional wordlists
      argv[1..].each { |arg| wordlist += (open_wordlist(arg)) }

      # apply operators first
      @operators.each do |(operator,args)|
        wordlist = wordlist.send(operator,*args)
      end

      # then apply modifiers
      @modifiers.each do |(method,args)|
        wordlist = wordlist.send(method,*args)
      end

      begin
        if @command
          wordlist.each do |word|
            system(@command.gsub('{}',word))
          end
        else
          wordlist.each do |word|
            puts word
          end
        end
      rescue CommandNotFound => error
        print_error(error.message)
        return -1
      end

      return 0
    end

    #
    # The option parser.
    #
    # @return [OptionParser]
    #
    def option_parser
      OptionParser.new do |opts|
        opts.banner = "usage: #{PROGRAM_NAME} { [options] WORDLIST ... | --build WORDLIST [FILE ...] }"

        opts.separator ""
        opts.separator "Wordlist Reading Options:"

        opts.on('-f','--format {txt|gzip|bz2|xz|zip|7zip}', FORMATS, 'Saves the output to FILE') do |format|
          @format = format
        end

        opts.on('--exec COMMAND','Runs the command with each word from the wordlist.', 'The string "{}" will be replaced with each word.') do |command|
          @command = command
        end

        opts.separator ""
        opts.separator "Wordlist Operations:"

        opts.on('-U','--union WORDLIST','Unions the wordlist with the other WORDLIST') do |wordlist|
          add_operator(:|, open_wordlist(wordlist))
        end

        opts.on('-I','--intersect WORDLIST','Intersects the wordlist with the other WORDLIST') do |wordlist|
          add_operator(:&, open_wordlist(wordlist))
        end

        opts.on('-S','--subtract WORDLIST','Subtracts the words from the WORDLIST') do |wordlist|
          add_operator(:-, open_wordlist(wordlist))
        end

        opts.on('-p','--product WORDLIST', 'Combines every word with the other words from WORDLIST') do |wordlist|
          add_operator(:*, open_wordlist(wordlist))
        end

        opts.on('-P','--power NUM', Integer, 'Combines every word with the other words from WORDLIST') do |power|
          add_operator(:**, power)
        end

        opts.on('-u','--unique','Filters out duplicate words') do
          add_operator(:uniq)
        end

        opts.separator ""
        opts.separator "Wordlist Modifiers:"

        opts.on('-C','--capitalize','Capitalize each word') do
          add_modifier(:capitalize)
        end

        opts.on('--uppercase', '--upcase','Converts each word to UPPERCASE') do
          add_modifier(:upcase)
        end

        opts.on('--lowercase', '--downcase','Converts each word to lowercase') do
          add_modifier(:downcase)
        end

        opts.on('-t','--tr CHARS:REPLACE','Translates the characters of each word') do |string|
          chars, replace = string.split(':',2)

          add_modifier(:tr, chars, replace)
        end

        opts.on('-s','--sub PATTERN:SUB','Replaces PATTERN with SUB in each word') do |string|
          pattern, replace = string.split(':',2)

          add_modifier(:sub, pattern, replace)
        end

        opts.on('-g','--gsub PATTERN:SUB','Replaces all PATTERNs with SUB in each word') do |string|
          pattern, replace = string.split(':',2)

          add_modifier(:gsub, pattern, replace)
        end

        opts.on('-m','--mutate PATTERN:SUB','Performs every possible substitution on each word') do |string|
          pattern, replace = string.split(':',2)

          add_modifier(:mutate, pattern, replace)
        end

        opts.on('-M','--mutate-case','Switches the case of each letter in each word') do
          add_modifier(:mutate_case)
        end

        opts.separator ""
        opts.separator "Wordlist Building Options:"

        opts.on('-b','--build WORDLIST','Builds a wordlist') do |wordlist|
          @mode   = :build
          @output = wordlist
        end

        opts.on('-a', '--[no-]append', TrueClass, 'Appends to the new wordlist instead of overwriting it') do |bool|
          @builder_options[:append] = bool
        end

        opts.on('-L','--lang LANG','The language to expect') do |lang|
          @builder_options[:lang] = lang
        end

        opts.on('--stop-words WORDS...','Ignores the stop words') do |words|
          @builder_options[:stop_words] = words.split
        end

        opts.on('--ignore-words WORDS...','Ignore the words') do |words|
          @builder_options[:ignore_words] = words.split
        end

        opts.on('--[no-]digits', TrueClass, 'Allow digits in the middle of words') do |bool|
          @builder_options[:digits] = bool
        end

        opts.on('--special-chars CHARS','Allows the given special characters inside of words') do |string|
          @builder_options[:special_chars] = string.chars
        end

        opts.on('--[no-]numbers', TrueClass, 'Parses whole numbers in addition to words') do |bool|
          @builder_options[:numbers] = bool
        end

        opts.on('--[no-]acronyms', TrueClass, 'Parses acronyms in addition to words') do |bool|
          @builder_options[:acronyms] = bool
        end

        opts.on('--[no-]normalize-case', TrueClass, 'Converts all words to lowercase') do |bool|
          @builder_options[:normalize_case] = bool
        end

        opts.on('--[no-]normalize-apostrophes', TrueClass, 'Removes "\'s" from words') do |bool|
          @builder_options[:normalize_apostrophes] = bool
        end

        opts.on('--[no-]normalize-acronyms', TrueClass, 'Removes the dots from acronyms') do |bool|
          @builder_options[:normalize_acronyms] = bool
        end

        opts.separator ""
        opts.separator "General Options:"

        opts.on('-V','--version','Print the version') do
          puts "#{PROGRAM_NAME} #{VERSION}"
          exit
        end

        opts.on('-h','--help','Print the help output') do
          puts opts
          exit
        end

        opts.separator ""
        opts.separator "Examples:"
        opts.separator "    #{PROGRAM_NAME} rockyou.txt.gz"
        opts.separator "    #{PROGRAM_NAME} passwords_short.txt passwords_long.txt"
        opts.separator "    #{PROGRAM_NAME} sport_teams.txt -p beers.txt -p digits.txt"
        opts.separator "    cat *.txt | #{PROGRAM_NAME} --build custom.txt"
        opts.separator ""
      end
    end

    #
    # Prints an error message to stderr.
    #
    # @param [String] error
    #   The error message.
    #
    def print_error(error)
      $stderr.puts "#{PROGRAM_NAME}: #{error}"
    end

    #
    # Prints a backtrace to stderr.
    #
    # @param [Exception] exception
    #   The exception.
    #
    def print_backtrace(exception)
      $stderr.puts "Oops! Looks like you've found a bug!"
      $stderr.puts "Please report the following text to: #{BUG_REPORT_URL}"
      $stderr.puts
      $stderr.puts "```"
      $stderr.puts "#{exception.full_message}"
      $stderr.puts "```"
    end

  end
end