# frozen_string_literal: true require 'thor' require 'git' require 'docker' require 'norad_cli/support/api_security_container_seed_script' require 'norad_cli/support/sectest_container' require 'norad_cli/support/ui_seed_generator' require 'rspec' require 'json' require 'rainbow' class Sectest < Thor include Thor::Actions @reserved_sectest_args = { target: ['-t', 'The IP or FQDN of the host to test'], ssh_user: ['-u', 'If the sectest requires authentication, then the username for authentication'], ssh_key: ['-k', 'If the sectest requires authentication, then the path to the ssh key file'], port: ['-p', 'The port to use for testing'], service_username: ['-e', 'Username to authenticate with the service, e.g. web application, database server, etc'], service_password: ['-r', 'Password to authenticate with the service'], web_service_protocol: ['-c', 'Protocol used to load the web application, http or https'], web_service_url_blacklist: ['-b', 'Comma separated list of words to avoid when spidering web application links'], web_service_auth_type: ['-a', 'Authentication method used by the web application'], web_service_starting_page_path: ['-g', 'Path where web application scan should begin, e.g. /'], web_service_login_form_username_field_name: ['-l', 'HTML form field name for the username portion of web application authentication'], web_service_login_form_password_field_name: ['-m', 'HTML form field name for the password portion of web application authentication'] } def self.source_root File.join(File.dirname(File.expand_path(__FILE__)), '../templates/') end # Loads a manifest file depending on the command # rubocop:disable Style/GuardClause def self.load_manifest(sectest_name) @@sectest_manifest = {} # Set defaults just in case no manifest.yml to overwrite @@sectest_manifest['registry'] = 'norad-registry.cisco.com:5000' @@sectest_manifest['version'] = 'latest' # Dynamically add options and description based on the needs of the sectest container if %w[build build:all build:image build:specs execute].include?(ARGV[1]) && sectest_name && !sectest_name.start_with?('-', '--') # Read in the program arguments if File.exist?("sectests/#{sectest_name}/manifest.yml") @@sectest_manifest = YAML.safe_load(File.read("sectests/#{sectest_name}/manifest.yml")) # Precautionary, remove all leading and trailing whitespace @@sectest_manifest['registry'].strip! @@sectest_manifest['version'].strip! else puts Rainbow("Error: #{sectest_name} sectest does not exist or it is missing sectests/#{sectest_name}/manifest.yml").red puts Rainbow('Exiting...').red exit(1) end end end # rubocop:enable Style/GuardClause def initialize(*args) super # Check if the command is being run from the repository root (all commands must be) root_dir? end # Load the manifest file if necessary # Correct set default registry and version load_manifest(ARGV[2]) # Used to prevent JSON::ParserError when parsing the output stream Docker.options[:chunk_size] = 1 desc 'scaffold TESTNAME', 'Create a new security test with standard files + testing' option :test_type, aliases: '-t', default: 'whole_host', desc: 'The security test type, Options: [authenticated|web_application|brute_force|ssl_crypto|ssh_crypto|whole_host]' option :registry, aliases: '-r', default: @@sectest_manifest['registry'], desc: 'The Docker registry to store docker images' option :version, aliases: '-v', default: @@sectest_manifest['version'], desc: 'The version of the security test' option :base_image, aliases: '-b', default: 'norad-registry.cisco.com:5000/norad:0.0.1', desc: 'Base Docker image to use (i.e. FROM field in the Dockerfile)' option :configurable, type: :boolean, aliases: '-c', desc: 'Is the security test configurable (e.g. Qualys username and password)' def scaffold(sectest_name) # Grab the current directory repo_dir = Dir.pwd # Check for valid test types if !%w[authenticated web_application brute_force ssl_crypto ssh_crypto whole_host].include?(options[:test_type]) say("#{options[:test_type]} is not a supported test type", :red) say('Exiting...', :red) exit(1) end # Set options for templates options[:name] = sectest_name options[:spec_class_name] = sectest_name.split('-').map { |t| t =~ /\d+/ ? t : t.capitalize! }.join # Error check to ensure this is a norad security test repository # Create the security tests standard files template('tool/Dockerfile.erb', "#{repo_dir}/sectests/#{sectest_name}/Dockerfile") template('tool/README.md.erb', "#{repo_dir}/sectests/#{sectest_name}/README.md") template('tool/manifest.yml.erb', "#{repo_dir}/sectests/#{sectest_name}/manifest.yml") # Create a starter wrapper script template('tool/wrapper.rb.erb', "#{repo_dir}/sectests/#{sectest_name}/#{sectest_name}-wrapper.rb") # Create the spec files template('tool/tool_spec.rb.erb', "#{repo_dir}/spec/#{sectest_name}/#{sectest_name}_spec.rb") if options[:test_type] == 'authenticated' template('tool/Dockerfile.auth.target.erb', "#{repo_dir}/spec/#{sectest_name}/targets/Dockerfile.secure") template('tool/Dockerfile.auth.target.erb', "#{repo_dir}/spec/#{sectest_name}/targets/Dockerfile.vulnerable") else template('tool/Dockerfile.unauth.target.erb', "#{repo_dir}/spec/#{sectest_name}/targets/Dockerfile.secure") template('tool/Dockerfile.unauth.target.erb', "#{repo_dir}/spec/#{sectest_name}/targets/Dockerfile.vulnerable") end end desc 'build', 'Build all sectest images and specs for the entire repository' option :debug, aliases: '-d', type: :boolean, default: true, desc: 'Turn on debugging messages (e.g. Docker build logs to stdout)' def build # Error check to ensure this is a plugin directory Dir.glob('sectests/*').select do |f| # Skip if the entry is not a directory next if !File.directory? f # Grab the name of the sectest sectest_name = f.split('/')[-1] # Load the manifest for the sectest Sectest.load_manifest(sectest_name) # Build all for the sectest send('build:all', sectest_name) end end # Define arguments and options desc 'build:image SECTESTNAME', 'Build the docker image for the security test SECTESTNAME' option :debug, aliases: '-d', type: :boolean, default: true, desc: 'Turn on debugging messages (e.g. Docker build logs to stdout)' define_method 'build:image' do |name| imgs_to_build = {} imgs_to_build["sectests/#{name}"] = "#{@@sectest_manifest['registry']}/#{name}:#{@@sectest_manifest['version']}" # Check for the Dockerfile if !dockerfile?(imgs_to_build.keys[0]) say("Missing #{imgs_to_build.keys[0]}/Dockerfile", :red) exit(1) end # Determine if base image needs to be built base_img = extract_base_img(imgs_to_build.keys[0]) while dockerfile?("base/#{base_img[0]}") imgs_to_build["base/#{base_img[0]}"] = base_img[1] base_img = extract_base_img(imgs_to_build.keys[-1]) end # Build the images in reverse (Note: Hashes enumerate their values in insertion order.) Docker.options[:read_timeout] = 36_000 imgs_to_build.keys.reverse_each do |img_dir| say("Building image #{img_dir}...", :green) Docker::Image.build_from_dir(img_dir, t: imgs_to_build[img_dir]) do |v| parse_json v if options[:debug] end end end # Define arguments and options desc 'build:specs SECTESTNAME', 'Build the spec images (test images) for the security test SECTESTNAME' option :debug, aliases: '-d', type: :boolean, default: true, desc: 'Turn on debugging messages (e.g. Docker build logs to stdout)' define_method 'build:specs' do |name| imgs_to_build = {} imgs_to_build["#{__dir__}/../templates/spec/support/Dockerfile.testserver"] = 'docker-images-test-results-server:latest' imgs_to_build["#{__dir__}/../templates/spec/support/Dockerfile.ubuntu_ssh"] = 'docker-images-test-ubuntu-ssh-server:latest' # Determine the Dockerfiles in the assessment spec dir Dir.glob("spec/#{name}/targets/Dockerfile.*[^~]").each do |entry| entry_components = entry.split('.') imgs_to_build[entry] = "#{name}-#{entry_components[-1]}:latest" end # Build the images Docker.options[:read_timeout] = 36_000 imgs_to_build.each_key do |img_dir| say("Building image #{img_dir}...", :green) docker_file = img_dir.split('/')[-1] nocache = docker_file['no-cache'] ? true : false Docker::Image.build_from_dir(img_dir.gsub(docker_file, ''), dockerfile: docker_file, t: imgs_to_build[img_dir], nocache: nocache) do |v| parse_json v if options[:debug] end end # Pull the apline image for base testing Docker::Image.create('fromImage' => 'alpine:3.4') end # Define arguments and options desc 'build:all SECTESTNAME', 'Build sectest images for SECTESTNAME and all testing images for SECTESTNAME' option :debug, aliases: '-d', type: :boolean, default: true, desc: 'Turn on debugging messages (e.g. Docker build logs to stdout)' define_method 'build:all' do |name| # Build the sectest image send('build:image', name) # Build the specs for testing the sectest send('build:specs', name) end # Dynamically add options and description based on the needs of the sectest container if ARGV[1] == 'execute' && ARGV[2] && !ARGV[2].start_with?('-', '--') desc "execute #{ARGV[2]}", "Execute #{ARGV[2]} against an arbitrary target" # Dynamically create options @@sectest_manifest['prog_args'].scan(/{(.*?)}/).each do |ar| if @reserved_sectest_args.key?(ar[0].to_sym) option ar[0].to_sym, aliases: @reserved_sectest_args[ar[0].to_sym][0], desc: @reserved_sectest_args[ar[0].to_sym][1] else option ar[0].to_sym end end else desc 'execute SECTESTNAME', 'Execute SECTESTNAME against an arbitrary target' end option :debug, aliases: '-d', type: :boolean, default: false, desc: 'Turn on debugging messages (e.g. Docker build logs to stdout, containers not removed!)' option :format, aliases: '-f', type: :boolean, default: false, desc: 'Print the JSON results formatted' def execute(sectest_name) # Warn users if debugging is enabled say('Warning: Debug enabled: containers must be removed manually', :yellow) && sleep(2) if options['debug'] # Ensure the results server is built by building the images specs (code reuse) send('build:specs', sectest_name) # Build the sectest image if necessary send('build:image', sectest_name) # Allocate an instance of the sectest sectest_instance = NoradCli::SecTestContainer.new(ARGV[2], @@sectest_manifest['registry'], @@sectest_manifest['version'], options) # Start the test sectest_instance.start # Print any debugging sectest_instance.output(options[:target]) if options[:debug] # Get the results results = sectest_instance.results say('Results are:', :green) formatted_results = options[:format] ? JSON.pretty_generate(JSON.parse(results)) : results puts formatted_results # Cleanup the sectest container sectest_instance.shutdown # Warn users if debugging is enabled say('Warning: Debug enabled: containers must be removed manually', :yellow) if options['debug'] end desc 'spec:image SECTESTNAME', 'Run the rspec tests for SECTESTNAME' option :verbose, aliases: '-v', type: :boolean, desc: 'Turn on verbose logging' option :debug, aliases: '-d', type: :boolean, desc: 'Turn on debugging' define_method 'spec:image' do |name| run_specs(name, thor_options: options) end desc 'spec', 'Run all rspec tests for the entire repo (all sectests)' option :verbose, aliases: '-v', type: :boolean, default: false, desc: 'Turn on verbose logging' option :debug, aliases: '-d', type: :boolean, desc: 'Turn on debugging' def spec # Error check to ensure this is a plugin directory specs = Dir.glob('sectests/*').map do |f| if File.directory? f f.split('/')[-1] end end.compact run_specs(*specs, thor_options: options) end desc 'seed', 'Create the containers.rb seed to import into the api' option :seedfile, aliases: '-s', type: :string, default: './containers.rb', desc: 'The name of the seed file to generate' option :docsite, aliases: '-d', type: :string, default: 'https://norad.gitlab.io/docs/', desc: 'Set the documentation site' def seed # Generate the seed file SeedGenerator.process_manifests(options[:seedfile], options[:docsite]) end desc 'seed:private_repo', 'Generate seed to import into UI. '\ 'Example: `norad sectest seed:private_repo ` '\ '(for all tests run without args)' define_method 'seed:private_repo' do |*manifest_paths| NoradCli::UiSeedGenerator.new(manifest_paths).process! end desc 'validate:image SECTESTNAME', 'Validate SECTESTNAME manifest.yml and readme.md' define_method 'validate:image' do |name| run_validations(name) end desc 'validate', 'Validate all manifest.yml and readme.md' def validate # Error check to ensure this is a plugin directory files = Dir.glob('sectests/*').map do |f| if File.directory? f f.split('/')[-1] end end.compact run_validations(*files) end no_tasks do def dockerfile?(img_dir) # Ensure the Dockerfile exists for the new tool File.file?("#{img_dir}/Dockerfile") end # Check for a base image def extract_base_img(img_dir) from_line = File.readlines("#{img_dir}/Dockerfile").select { |line| line =~ /^FROM/ } # Detect missing FROM lines if from_line.length.zero? puts Rainbow("Error: #{img_dir}/Dockerfile missing a FROM line!").red exit(1) else from_line_arr = from_line[0].split(' ') end from_image = from_line[0][%r{\AFROM\s+(.*?\/)?(.*?)(:.*?)?\Z}i, 2] || raise('bad from') [from_image, from_line_arr[1]] end # Ensure commands are run from the root dir def root_dir? %w[base spec sectests].each do |dirrepo_name| if !File.exist?(dirrepo_name) say("Commands must be run from the root of the test repository\nExiting....", :red) exit(1) end end end end no_commands do def run_specs(*specs, **opts) thor_options = opts.fetch(:thor_options, {}) # Set environment variables if thor_options[:verbose] ENV['ENABLE_LOGS'] = 'true' end if thor_options[:debug] ENV['ENABLE_NORAD_DEBUG'] = 'true' end ENV['SCAN_ASSESSMENT'] = 'true' ENV['TEST_RESULTS_SERVER_IMAGE'] = 'docker-images-test-results-server' ENV['UBUNTU_SSH_SERVER_IMAGE'] = 'docker-images-test-ubuntu-ssh-server' codes = specs.map do |spec| # Run the tests if File.exist?("spec/#{spec}/#{spec}_spec.rb") RSpec.clear_examples say("Testing spec/#{spec}/#{spec}_spec.rb") RSpec::Core::Runner.run(["spec/#{spec}/#{spec}_spec.rb"], $stderr, $stdout) else say("Warning: spec/#{spec}/#{spec}_spec.rb does not exist!\n", :yellow) say("Warning: No tests will be run for #{spec}\n", :yellow) 0 # always return 0 if the spec doesn't exist end end exit(codes.detect(-> { 0 }, &:nonzero?)) end def run_validations(*files) codes = files.map do |file| ENV['sectest_name'] = file # Validate the readme file say("Validating README for #{file}") RSpec.clear_examples RSpec::Core::Runner.run(["#{File.dirname(File.expand_path(__FILE__))}/../support/readme_spec.rb"], $stderr, $stdout) # Validate the manifest file say("Validating manifest.yml for #{file}") RSpec.clear_examples RSpec::Core::Runner.run(["#{File.dirname(File.expand_path(__FILE__))}/../support/manifest_spec.rb"], $stderr, $stdout) end exit(codes.detect(-> { 0 }, &:nonzero?)) end end private def parse_json(stream) stream.split(/\r\n/).each do |string| parsed = JSON.parse(string) $stdout.print(parsed['stream']) if parsed.is_a?(Hash) end rescue JSON::ParserError $stdout.print stream end end