diff --git a/README.md b/README.md index 6bca5ca..0f1e769 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Commands: grep help import + new print scan ``` @@ -95,6 +96,18 @@ Convert an nmap XML scan file to JSON: $ ronin-nmap convert scan.xml scan.json ``` +Generate a new nmap scanner Ruby script: + +```shell +$ ronin-nmap new scanner.rb --ports 22,80,443,8000-9000 --target example.com +``` + +Generate a new nmap XML parser script: + +```shell +$ ronin-nmap new parser.rb --parser --xml-file path/to/nmap.xml --printing +``` + ## Examples Performing an `nmap` scan and returning the parsed nmap XML data: diff --git a/data/templates/script.rb.erb b/data/templates/script.rb.erb new file mode 100644 index 0000000..fc154b9 --- /dev/null +++ b/data/templates/script.rb.erb @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby + +require 'ronin/nmap' + +<%- if @script_type == :parser -%> +<%- if @xml_file -%> +xml = Ronin::Nmap.parse(<%= @xml_file.inspect %>) +<%- else -%> +xml = Ronin::Nmap.parse(ARGV[0]) +<%- end -%> +<%- else -%> +xml = Ronin::Nmap.scan( +<%- case @targets.length -%> +<%- when 0 -%> + ARGV[0], +<%- when 1 -%> + <%= @targets[0].inspect %>, +<%- else -%> + <%= @targets.inspect %>, +<%- end -%> +<%- if @syn_scan -%> + syn_scan: true, +<%- end -%> +<%- if @ports -%> + ports: <%= @ports.inspect %>, +<%- else -%> + # ports: [22, 80, 443, 8000..9000], +<%- end -%> +<%- if @xml_file -%> + xml_file: <%= @xml_file.inspect %> +<%- else -%> + # xml_file: "path/to/file.xml" +<%- end -%> +) +<%- end -%> +<% if @features[:printing] -%> + +xml.each_host do |host| + puts "[ #{host.ip} ]" + + host.each_port do |port| + puts " #{port.number}/#{port.protocol}\t#{port.state}\t#{port.service}" + + port.scripts.each do |id,script| + puts " [ #{id} ]" + + script.output.each_line { |line| puts " #{line}" } + end + end +end +<%- end -%> +<%- if @features[:import] -%> + +Ronin::DB.connect +Ronin::Nmap::Importer.import(xml) +<%- end -%> diff --git a/gemspec.yml b/gemspec.yml index 9176c51..64c5f94 100644 --- a/gemspec.yml +++ b/gemspec.yml @@ -26,6 +26,7 @@ generated_files: - man/ronin-nmap-dump.1 - man/ronin-nmap-grep.1 - man/ronin-nmap-import.1 + - man/ronin-nmap-new.1 - man/ronin-nmap-print.1 - man/ronin-nmap-scan.1 diff --git a/lib/ronin/nmap/cli/commands/new.rb b/lib/ronin/nmap/cli/commands/new.rb new file mode 100644 index 0000000..6e0fddb --- /dev/null +++ b/lib/ronin/nmap/cli/commands/new.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true +# +# ronin-nmap - A Ruby library for automating nmap and importing nmap scans. +# +# Copyright (c) 2023-2024 Hal Brodigan (postmodern.mod3@gmail.com) +# +# ronin-nmap is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ronin-nmap is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with ronin-nmap. If not, see . +# + +require 'ronin/nmap/cli/command' +require 'ronin/nmap/root' + +require 'ronin/core/cli/generator' + +module Ronin + module Nmap + class CLI + module Commands + # + # Generates a new nmap ruby script. + # + # ## Usage + # + # ronin-nmap new [options] FILE + # + # ## Options + # + # --parser Generate a nmap XML parser script + # --scanner Generate a nmap scanner script + # --printing Adds additional printing of the nmap scan data + # --import Also import the nmap XML scan data + # --xml-file XML_FILE Sets the XML file to write to or parse + # -p {PORT | [PORT1]-[PORT2]},... Sets the port range to scan + # --ports + # --target TARGET Sets the targets to scan (Defaults: ARGV[0]) + # -h, --help Print help information + # + # ## Arguments + # + # FILE The path to the new nmap ruby script. + # + # ## Examples + # + # ronin-nmap new scanner.rb --ports 22,80,443,8000-9000 --target example.com + # ronin-nmap new parser.rb --parser --xml-file path/to/nmap.xml --printing + # + class New < Command + + include Core::CLI::Generator + + template_dir File.join(ROOT,'data','templates') + + usage '[options] FILE' + + option :parser, desc: 'Generate a nmap XML parser script' do + @script_type = :parser + end + + option :scanner, desc: 'Generate a nmap scanner script' do + @script_type = :scanner + end + + option :printing, desc: 'Adds additional printing of the nmap scan data' do + @features[:printing] = true + end + + option :import, desc: 'Also import the nmap XML scan data' do + @features[:import] = true + end + + option :xml_file, value: { + type: String, + usage: 'XML_FILE' + }, + desc: 'Sets the XML file to write to or parse' do |file| + @xml_file = file + end + + option :ports, short: '-p', + value: { + type: String, + usage: '{PORT | [PORT1]-[PORT2]},...' + }, + desc: 'Sets the port range to scan' do |ports| + @ports = parse_port_range(ports) + rescue ArgumentError => error + raise(OptionParser::InvalidArgument,error.message) + end + + option :target, value: { + type: String, + usage: 'TARGET' + }, + desc: 'Sets the targets to scan (Defaults: ARGV[0])' do |target| + @targets << target + end + + argument :path, desc: 'The path to the new nmap ruby script' + + description 'Generates a new nmap ruby script' + + man_page 'ronin-nmap-new.1' + + examples [ + "scanner.rb --ports 22,80,443,8000-9000 --target example.com", + "parser.rb --parser --xml-file path/to/nmap.xml --printing" + ] + + # The script type. + # + # @return [:scan, :parse] + attr_reader :script_type + + # The optioanl XML file to write to or parse. + # + # @return [String, nil] + attr_reader :xml_file + + # The optional ports to scan. + # + # @return [Array, "-", nil] + attr_reader :ports + + # The targets to scan. + # + # @return [Array] + attr_reader :targets + + # Additional features. + # + # @return [Hash{Symbol => Boolean}] + attr_reader :features + + # + # Initializes the `ronin-nmap new` command. + # + # @param [Hash{Symbol => Object}] kwargs + # Additional keyword arguments for the command. + # + def initialize(**kwargs) + super(**kwargs) + + @script_type = :scanner + @targets = [] + @features = {} + end + + # + # Runs the `ronin-nmap new` command. + # + # @param [String] file + # The path to the new nmap ruby script. + # + def run(file) + @directory = File.dirname(file) + + mkdir @directory unless File.directory?(@directory) + + erb "script.rb.erb", file + chmod '+x', file + end + + # + # Parses a port range. + # + # @param [String] ports + # The port range to parse. + # + # @return [Array, "-"] + # The parsed port range. + # + # @raise [ArgumentError] + # An invalid port range was given. + # + def parse_port_range(ports) + case ports + when '-' then '-' + else + ports.split(',').map do |port| + case port + when /\A\d+-\d+\z/ + start, stop = port.split('-',2) + + (start.to_i..stop.to_i) + when /\A\d+-\z/ + start = port.chomp('-') + + (start.to_i..) + when /\A-\d+\z/ + stop = port[1..] + + (..stop.to_i) + when /\A\d+\z/ + port.to_i + else + raise(ArgumentError,"invalid port range: #{ports.inspect}") + end + end + end + end + + end + end + end + end +end diff --git a/man/ronin-nmap-new.1.md b/man/ronin-nmap-new.1.md new file mode 100644 index 0000000..99cda9e --- /dev/null +++ b/man/ronin-nmap-new.1.md @@ -0,0 +1,70 @@ +# ronin-nmap-new 1 "2023-03-01" Ronin Nmap "User Manuals" + +## NAME + +ronin-nmap-new - Generates a new nmap ruby script + +## SYNOPSIS + +`ronin-nmap new` [options] *FILE* + +## DESCRIPTION + +Generates a new nmap scanner or parser Ruby script that uses the `ronin-nmap` +library. + +## ARGUMENTS + +*FILE* +: The path to the new Ruby script to generate. + +## OPTIONS + +`--parser` +: Generates a new nmap XML parser Ruby script. + +`--scanner` +: Generates a new nmap scanner Ruby script. + +`--printing` +: Adds additional code to the Ruby script that prints the nmap XML scan data. + Is compatible with both `--parser` and `--scanner`. + +`--import` +: Adds additional code to the Ruby script that imports the nmap XML scan data. + Is compatible with both `--parser` and `--scanner`. + +`--xml-file` *XML_FILE* +: Parses or writes the scan results to the given XML File. + Is compatible with both `--parser` and `--scanner`. + +`-p`, `--port` {*PORT* \| \[*PORT1*\]-\[*PORT2*\]},... +: Specifies the ports to scan. Not compatible with the `--parser` option. + +`--target` *TARGET* +: Adds a target to scan. May be a host name, IP, IP CIDR range (ex: + `192.168.1.1/24`), or IP glob range (ex: `192.168.*.1-4`). + Not compatible with the `--parser` option. + +`-h`, `--help` +: Print help information + +## EXAMPLES + +Generates a new nmap scanner Ruby script that scans `example.com`, ports 22, 80, +443, and 8000 through 9000: + + $ ronin-nmap new scanner.rb --ports 22,80,443,8000-9000 --target example.com + +Generates a new nmap XML parser script that parses `path/to/nmap.xml` and prints +the scan information. + + $ ronin-nmap new parser.rb --parser --xml-file path/to/nmap.xml --printing + +## AUTHOR + +Postmodern + +## SEE ALSO + +[ronin-nmap-scan](ronin-nmap-scan.1.md), [ronin-nmap-print](ronin-nmap-print.1.md), [ronin-nmap-import](ronin-nmap-import.1.md) diff --git a/spec/cli/commands/new_spec.rb b/spec/cli/commands/new_spec.rb new file mode 100644 index 0000000..a57d6fd --- /dev/null +++ b/spec/cli/commands/new_spec.rb @@ -0,0 +1,509 @@ +require 'spec_helper' +require 'ronin/nmap/cli/commands/new' + +require 'tmpdir' + +describe Ronin::Nmap::CLI::Commands::New do + describe "options" do + before { subject.option_parser.parse(argv) } + + context "when given the '--parser' option" do + let(:argv) { %w[--parser] } + + it "must set #script_type to :parser" do + expect(subject.script_type).to eq(:parser) + end + end + + context "when given the '--scanner ' option" do + let(:argv) { %w[--parser --scanner] } + + it "must set #script_type to :scanner" do + expect(subject.script_type).to eq(:scanner) + end + end + + context "when given the '--printing' option" do + let(:argv) { %w[--printing] } + + it "must set :printing in #features" do + expect(subject.features[:printing]).to be(true) + end + end + + context "when given the '--import' option" do + let(:argv) { %w[--import] } + + it "must set :import in #features" do + expect(subject.features[:import]).to be(true) + end + end + + context "when given the '--xml-file FILE' option" do + let(:file) { 'path/to/nmap.xml' } + let(:argv) { ['--xml-file', file] } + + it "must set #xml_file to the given FILE argument" do + expect(subject.xml_file).to eq(file) + end + end + + context "when given the '--ports PORT,...' option" do + let(:ports) { [22, 80, 443] } + let(:argv) { ['--ports', "#{ports.join(',')}"] } + + it "must set #ports to an Array of port Integers" do + expect(subject.ports).to eq(ports) + end + end + + context "when given the '--ports PORT1-PORT2,...' option" do + let(:start_port1) { 1 } + let(:stop_port1) { 1024 } + let(:start_port2) { 8000 } + let(:stop_port2) { 9000 } + + let(:argv) do + ['--ports', "#{start_port1}-#{stop_port1},#{start_port2}-#{stop_port2}"] + end + + it "must set #ports to an Array of Ranges of Integers" do + expect(subject.ports).to eq( + [ + (start_port1..stop_port1), + (start_port2..stop_port2) + ] + ) + end + end + + context "when given the '--ports PORT1,PORT2,PORT3-PORT4,...' option" do + let(:port1) { 80 } + let(:port2) { 443 } + let(:start_port1) { 1 } + let(:stop_port1) { 1024 } + let(:start_port2) { 8000 } + let(:stop_port2) { 9000 } + + let(:argv) do + ['--ports', "#{port1},#{port2},#{start_port1}-#{stop_port1},#{start_port2}-#{stop_port2}"] + end + + it "must set #ports to an Array of Integers and Ranges of Integers" do + expect(subject.ports).to eq( + [ + port1, + port2, + (start_port1..stop_port1), + (start_port2..stop_port2) + ] + ) + end + end + + context "when given the '--ports -' option" do + let(:argv) { %w[--ports -] } + + it "must set #ports to '-'" do + expect(subject.ports).to eq('-') + end + end + + context "when given the '--target TARGET' option" do + let(:target1) { 'example.com' } + let(:target2) { '192.168.1.1' } + let(:argv) { ['--target', target1, '--target', target2] } + + it "must append the target value to #targets" do + expect(subject.targets).to eq([target1, target2]) + end + end + end + + describe "#initialize" do + it "must default #script_type to :scanner" do + expect(subject.script_type).to eq(:scanner) + end + + it "must initialize #targets to an empty Array" do + expect(subject.targets).to eq([]) + end + + it "must initialize #features to an empty Hash" do + expect(subject.features).to eq({}) + end + end + + describe "#run" do + let(:tempdir) { Dir.mktmpdir('test-ronin-nmap-new') } + let(:path) { File.join(tempdir,'test_script.rb') } + + let(:argv) { [] } + + before do + subject.option_parser.parse(argv) + subject.run(path) + end + + it "must generate a new file containing a new `Ronin::Nmap.scan(...)`" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + # ports: [22, 80, 443, 8000..9000], + # xml_file: "path/to/file.xml" + ) + RUBY + ) + end + + it "must make the file executable" do + expect(File.executable?(path)).to be(true) + end + + context "when the parent directory does not exist yet" do + let(:path) { File.join(tempdir,'does_not_exist_yet','test_script.rb') } + + it "must create the parent directory" do + expect(File.directory?(File.dirname(path))).to be(true) + end + end + + context "when given the '--parser' option" do + let(:argv) { %w[--parser] } + + it "must generate a Ruby script that calls `Ronin::Nmap.parse(...)` instead" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.parse(ARGV[0]) + RUBY + ) + end + + context "and when given the '--xml-file FILE' option" do + let(:file) { 'path/to/nmap.xml' } + let(:argv) { super() + ['--xml-file', file] } + + it "must include the given file path instead of `ARGV[0]`" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.parse(#{file.inspect}) + RUBY + ) + end + end + + context "when given the '--printing' option" do + let(:argv) { super() + %w[--printing] } + + it "must append additional code to print the nmap XML scan data" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.parse(ARGV[0]) + + xml.each_host do |host| + puts "[ \#{host.ip} ]" + + host.each_port do |port| + puts " \#{port.number}/\#{port.protocol}\\t\#{port.state}\\t\#{port.service}" + + port.scripts.each do |id,script| + puts " [ \#{id} ]" + + script.output.each_line { |line| puts " \#{line}" } + end + end + end + RUBY + ) + end + end + + context "and when given the '--scanner' option" do + let(:argv) { super() + %w[--scanner] } + + it "must generate a new file containing a new `Ronin::Nmap.scan(...)` instead" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + # ports: [22, 80, 443, 8000..9000], + # xml_file: "path/to/file.xml" + ) + RUBY + ) + end + end + end + + context "when given the '--printing' option" do + let(:argv) { %w[--printing] } + + it "must append additional code to print the nmap XML scan data" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + # ports: [22, 80, 443, 8000..9000], + # xml_file: "path/to/file.xml" + ) + + xml.each_host do |host| + puts "[ \#{host.ip} ]" + + host.each_port do |port| + puts " \#{port.number}/\#{port.protocol}\\t\#{port.state}\\t\#{port.service}" + + port.scripts.each do |id,script| + puts " [ \#{id} ]" + + script.output.each_line { |line| puts " \#{line}" } + end + end + end + RUBY + ) + end + end + + context "when given the '--import' option" do + let(:argv) { %w[--import] } + + it "must append additional code to print the nmap XML scan data" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + # ports: [22, 80, 443, 8000..9000], + # xml_file: "path/to/file.xml" + ) + + Ronin::DB.connect + Ronin::Nmap::Importer.import(xml) + RUBY + ) + end + end + + context "when given the '--xml-file FILE' option" do + let(:file) { 'path/to/nmap.xml' } + let(:argv) { ['--xml-file', file] } + + it "must add an `xml_file:` keyword argument to `Ronin::Nmap.scan(...)` with the given file" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + # ports: [22, 80, 443, 8000..9000], + xml_file: #{file.inspect} + ) + RUBY + ) + end + end + + context "when given the '--ports PORT,...' option" do + let(:ports) { [22, 80, 443] } + let(:argv) { ['--ports', "#{ports.join(',')}"] } + + it "must add an `ports:` keyword argument to `Ronin::Nmap.scan(...)` with an Array of the given port numbers" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + ports: #{ports.inspect}, + # xml_file: "path/to/file.xml" + ) + RUBY + ) + end + end + + context "when given the '--ports PORT1-PORT2,...' option" do + let(:start_port1) { 1 } + let(:stop_port1) { 1024 } + let(:start_port2) { 8000 } + let(:stop_port2) { 9000 } + let(:ports) do + [ + (start_port1..stop_port1), + (start_port2..stop_port2) + ] + end + + let(:argv) do + ['--ports', "#{start_port1}-#{stop_port1},#{start_port2}-#{stop_port2}"] + end + + it "must add an `ports:` keyword argument to `Ronin::Nmap.scan(...)` with an Array of the given port ranges" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + ports: #{ports.inspect}, + # xml_file: "path/to/file.xml" + ) + RUBY + ) + end + end + + context "when given the '--ports PORT1,PORT2,PORT3-PORT4,...' option" do + let(:port1) { 80 } + let(:port2) { 443 } + let(:start_port1) { 1 } + let(:stop_port1) { 1024 } + let(:start_port2) { 8000 } + let(:stop_port2) { 9000 } + let(:ports) do + [ + port1, + port2, + (start_port1..stop_port1), + (start_port2..stop_port2) + ] + end + + let(:argv) do + ['--ports', "#{port1},#{port2},#{start_port1}-#{stop_port1},#{start_port2}-#{stop_port2}"] + end + + it "must add an `ports:` keyword argument to `Ronin::Nmap.scan(...)` with an Array of the given port numbers and ranges" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + ports: #{ports.inspect}, + # xml_file: "path/to/file.xml" + ) + RUBY + ) + end + end + + context "when given the '--ports -' option" do + let(:argv) { %w[--ports -] } + + it "must add an `ports:` keyword argument to `Ronin::Nmap.scan(...)` with '-'" do + expect(File.read(path)).to eq( + <<~RUBY + #!/usr/bin/env ruby + + require 'ronin/nmap' + + xml = Ronin::Nmap.scan( + ARGV[0], + ports: "-", + # xml_file: "path/to/file.xml" + ) + RUBY + ) + end + end + end + + describe "#parse_port_range" do + context "when given 'PORT,...'" do + let(:ports) { [22, 80, 443] } + let(:string) { ports.join(',') } + + it "must parse the string into an Array of port Integers" do + expect(subject.parse_port_range(string)).to eq(ports) + end + end + + context "when given 'PORT1-PORT2,...'" do + let(:start_port1) { 1 } + let(:stop_port1) { 1024 } + let(:start_port2) { 8000 } + let(:stop_port2) { 9000 } + + let(:string) do + "#{start_port1}-#{stop_port1},#{start_port2}-#{stop_port2}" + end + + it "must parse the string into an Array of Ranges of Integers" do + expect(subject.parse_port_range(string)).to eq( + [ + (start_port1..stop_port1), + (start_port2..stop_port2) + ] + ) + end + end + + context "when given 'PORT1,PORT2,PORT3-PORT4,...'" do + let(:port1) { 80 } + let(:port2) { 443 } + let(:start_port1) { 1 } + let(:stop_port1) { 1024 } + let(:start_port2) { 8000 } + let(:stop_port2) { 9000 } + + let(:string) do + "#{port1},#{port2},#{start_port1}-#{stop_port1},#{start_port2}-#{stop_port2}" + end + + it "must parse the string into an Array of Integers and Ranges of Integers" do + expect(subject.parse_port_range(string)).to eq( + [ + port1, + port2, + (start_port1..stop_port1), + (start_port2..stop_port2) + ] + ) + end + end + + context "when given '-'" do + it "must return '-'" do + expect(subject.parse_port_range('-')).to eq('-') + end + end + end +end