require 'ostruct' # This is a helper class for managing Scenario imports, used by the ScenarioImportsController. This class behaves much # like a normal ActiveRecord object, with validations and callbacks. However, it is never persisted to the database. class ScenarioImport include ActiveModel::Model include ActiveModel::Callbacks include ActiveModel::Validations::Callbacks DANGEROUS_AGENT_TYPES = %w[Agents::ShellCommandAgent] URL_REGEX = /\Ahttps?:\/\//i attr_accessor :file, :url, :data, :do_import, :merges attr_reader :user before_validation :parse_file before_validation :fetch_url validate :validate_presence_of_file_url_or_data validates_format_of :url, :with => URL_REGEX, :allow_nil => true, :allow_blank => true, :message => "appears to be invalid" validate :validate_data validate :generate_diff def step_one? data.blank? end def step_two? data.present? end def set_user(user) @user = user end def existing_scenario @existing_scenario ||= user.scenarios.find_by(:guid => parsed_data["guid"]) end def dangerous? (parsed_data['agents'] || []).any? { |agent| DANGEROUS_AGENT_TYPES.include?(agent['type']) } end def parsed_data @parsed_data ||= (data && JSON.parse(data) rescue {}) || {} end def agent_diffs @agent_diffs || generate_diff end def import_confirmed? do_import == "1" end def import(options = {}) success = true guid = parsed_data['guid'] description = parsed_data['description'] name = parsed_data['name'] links = parsed_data['links'] control_links = parsed_data['control_links'] || [] tag_fg_color = parsed_data['tag_fg_color'] tag_bg_color = parsed_data['tag_bg_color'] icon = parsed_data['icon'] source_url = parsed_data['source_url'].presence || nil @scenario = user.scenarios.where(:guid => guid).first_or_initialize @scenario.update!(name: name, description: description, source_url: source_url, public: false, tag_fg_color: tag_fg_color, tag_bg_color: tag_bg_color, icon: icon) unless options[:skip_agents] created_agents = agent_diffs.map do |agent_diff| agent = agent_diff.agent || Agent.build_for_type("Agents::" + agent_diff.type.incoming, user) agent.guid = agent_diff.guid.incoming agent.attributes = { :name => agent_diff.name.updated, :disabled => agent_diff.disabled.updated, # == "true" :options => agent_diff.options.updated, :scenario_ids => [@scenario.id] } agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present? agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present? agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true" agent.service_id = agent_diff.service_id.updated if agent_diff.service_id.present? unless agent.save success = false errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}") end agent end if success links.each do |link| receiver = created_agents[link['receiver']] source = created_agents[link['source']] receiver.sources << source unless receiver.sources.include?(source) end control_links.each do |control_link| controller = created_agents[control_link['controller']] control_target = created_agents[control_link['control_target']] controller.control_targets << control_target unless controller.control_targets.include?(control_target) end end end success end def scenario @scenario || @existing_scenario end protected def parse_file if data.blank? && file.present? self.data = file.read.force_encoding(Encoding::UTF_8) end end def fetch_url if data.blank? && url.present? && url =~ URL_REGEX self.data = Faraday.get(url).body end end def validate_data if data.present? @parsed_data = JSON.parse(data) rescue {} if (%w[name guid agents] - @parsed_data.keys).length > 0 errors.add(:base, "The provided data does not appear to be a valid Scenario.") self.data = nil end else @parsed_data = nil end end def validate_presence_of_file_url_or_data unless file.present? || url.present? || data.present? errors.add(:base, "Please provide either a Scenario JSON File or a Public Scenario URL.") end end def generate_diff @agent_diffs = (parsed_data['agents'] || []).map.with_index do |agent_data, index| # AgentDiff is defined at the end of this file. agent_diff = AgentDiff.new(agent_data, parsed_data['schema_version']) if existing_scenario # If this Agent exists already, update the AgentDiff with the local version's information. agent_diff.diff_with! existing_scenario.agents.find_by(:guid => agent_data['guid']) begin # Update the AgentDiff with any hand-merged changes coming from the UI. This only happens when this # Agent already exists locally and has conflicting changes. agent_diff.update_from! merges[index.to_s] if merges rescue JSON::ParserError errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.") end end if agent_diff.requires_service? && merges.present? && merges[index.to_s].present? && merges[index.to_s]['service_id'].present? agent_diff.service_id = AgentDiff::FieldDiff.new(merges[index.to_s]['service_id'].to_i) end agent_diff end end # AgentDiff is a helper object that encapsulates an incoming Agent. All fields will be returned as an array # of either one or two values. The first value is the incoming value, the second is the existing value, if # it differs from the incoming value. class AgentDiff < OpenStruct class FieldDiff attr_accessor :incoming, :current, :updated def initialize(incoming) @incoming = incoming @updated = incoming end def set_current(current) @current = current @requires_merge = (incoming != current) end def requires_merge? @requires_merge end end def initialize(agent_data, schema_version) super() @schema_version = schema_version @requires_merge = false self.agent = nil store! agent_data end BASE_FIELDS = %w[name schedule keep_events_for propagate_immediately disabled guid] FIELDS_REQUIRING_TRANSLATION = %w[keep_events_for] def agent_exists? !!agent end def requires_merge? @requires_merge end def requires_service? !!agent_instance.try(:oauthable?) end def store!(agent_data) self.type = FieldDiff.new(agent_data["type"].split("::").pop) self.options = FieldDiff.new(agent_data['options'] || {}) BASE_FIELDS.each do |option| if agent_data.has_key?(option) value = agent_data[option] value = send(:"translate_#{option}", value) if option.in?(FIELDS_REQUIRING_TRANSLATION) self[option] = FieldDiff.new(value) end end end def translate_keep_events_for(old_value) if schema_version < 1 # Was stored in days, now is stored in seconds. old_value.to_i.days else old_value end end def schema_version (@schema_version || 0).to_i end def diff_with!(agent) return unless agent.present? self.agent = agent type.set_current(agent.short_type) options.set_current(agent.options || {}) @requires_merge ||= type.requires_merge? @requires_merge ||= options.requires_merge? BASE_FIELDS.each do |field| next unless self[field].present? self[field].set_current(agent.send(field)) @requires_merge ||= self[field].requires_merge? end end def update_from!(merges) each_field do |field, value, selection_options| value.updated = merges[field] end if options.requires_merge? options.updated = JSON.parse(merges['options']) end end def each_field boolean = [["True", "true"], ["False", "false"]] yield 'name', name if name.requires_merge? yield 'schedule', schedule, Agent::SCHEDULES.map {|s| [AgentHelper.builtin_schedule_name(s), s] } if self['schedule'].present? && schedule.requires_merge? yield 'keep_events_for', keep_events_for, Agent::EVENT_RETENTION_SCHEDULES if self['keep_events_for'].present? && keep_events_for.requires_merge? yield 'propagate_immediately', propagate_immediately, boolean if self['propagate_immediately'].present? && propagate_immediately.requires_merge? yield 'disabled', disabled, boolean if disabled.requires_merge? end def agent_instance "Agents::#{self.type.updated}".constantize.new end end end