# frozen_string_literal: true module Mihari class Rule < Service include Concerns::FalsePositiveNormalizable include Concerns::FalsePositiveValidatable # @return [Hash] attr_reader :data # @return [Array, nil] attr_reader :errors # @return [Time] attr_reader :base_time # # Initialize # # @param [Hash] data # def initialize(**data) super() @data = data.deep_symbolize_keys @errors = nil @base_time = Time.now.utc validate! end # # @return [Boolean] # def errors? return false if @errors.nil? !@errors.empty? end def [](key) data key.to_sym end # # @return [String] # def id data[:id] end # # @return [String] # def title data[:title] end # # @return [String] # def description data[:description] end # # @return [String] # def yaml data.deep_stringify_keys.to_yaml end # # @return [Array] # def queries data[:queries] end # # @return [Array] # def data_types data[:data_types] end # # @return [Date, nil] # def created_on data[:created_on] end # # @return [Date, nil] # def updated_on data[:updated_on] end # # @return [Array] # def tags data[:tags].uniq.filter_map do |name| Models::Tag.find_or_create_by(name: name) end end # # @return [Array] # def taggings tags.map { |tag| Models::Tagging.find_or_create_by(tag_id: tag.id, rule_id: id) } end # # @return [Array] # def falsepositives @falsepositives ||= data[:falsepositives].map { |fp| normalize_falsepositive fp } end # # @return [Integer, nil] # def artifact_ttl data[:artifact_ttl] end # # Returns a list of artifacts matched with queries/analyzers (with the rule ID) # # @return [Array] # def artifacts analyzer_results.flat_map do |result| artifacts = result.value! artifacts.map do |artifact| artifact.tap { |tapped| tapped.rule_id = id } end end end # # Normalize artifacts # - Reject invalid artifacts (for just in case) # - Select artifacts with allowed data types # - Reject artifacts with false positive values # - Set rule ID # # @return [Array] # def normalized_artifacts valid_artifacts = artifacts.uniq(&:data).select(&:valid?) date_type_allowed_artifacts = valid_artifacts.select { |artifact| data_types.include? artifact.data_type } date_type_allowed_artifacts.reject { |artifact| falsepositive? artifact.data } end # # Uniquify artifacts (assure rule level uniqueness) # # @return [Array] # def unique_artifacts normalized_artifacts.select do |artifact| artifact.unique?(base_time: base_time, artifact_ttl: artifact_ttl) end end # # Enriched artifacts # # @return [Array] # def enriched_artifacts @enriched_artifacts ||= unique_artifacts.map do |artifact| serial_enrichers.each { |enricher| enricher.result(artifact) } Parallel.each(parallel_enrichers) { |enricher| enricher.result(artifact) } artifact end end # # Bulk emit # # @return [Array] # def bulk_emit return [] if enriched_artifacts.empty? [].tap do |out| out << serial_emitters.map { |emitter| emitter.result(enriched_artifacts).value_or(nil) } out << Parallel.map(parallel_emitters) { |emitter| emitter.result(enriched_artifacts).value_or(nil) } end.flatten.compact end # # Set artifacts & run emitters in parallel # # @return [Mihari::Models::Alert, nil] # def call # Validate analyzers & emitters before using them analyzers emitters alert_or_something = bulk_emit # Return Mihari::Models::Alert created by the database emitter alert_or_something.find { |res| res.is_a?(Mihari::Models::Alert) } end # # @return [Mihari::Models::Rule] # def model Mihari::Models::Rule.find(id).tap do |rule| rule.title = title rule.description = description rule.data = data rule.taggings = taggings end rescue ActiveRecord::RecordNotFound Mihari::Models::Rule.new( id: id, title: title, description: description, data: data, taggings: taggings ) end # # @return [Boolean] # def diff? model = Mihari::Models::Rule.find(id) model.data != diff_comparable_data rescue ActiveRecord::RecordNotFound false end # # @return [Boolean] # def exists? Mihari::Models::Rule.exists? id end def update_or_create model.save end class << self # # Load rule from YAML string # # @param [String] yaml # # @return [Mihari::Rule] # def from_yaml(yaml) data = YAML.safe_load(ERB.new(yaml).result, permitted_classes: [Date, Symbol]) new(**data) end # # @param [Mihari::Models::Rule] model # # @return [Mihari::Rule] # def from_model(model) new(**model.data) end end private # # @return [Hash] # def diff_comparable_data # data is serialized as JSON so dates (created_on & updated_on) are stringified in there # thus dates & (hash) keys have to be stringified when comparing data.deep_dup.tap do |data| data[:created_on] = created_on.to_s unless created_on.nil? data[:updated_on] = updated_on.to_s unless updated_on.nil? end.deep_stringify_keys end # # Check whether a value is a falsepositive value or not # # @return [Boolean] # def falsepositive?(artifact) return true if falsepositives.include?(artifact) regexps = falsepositives.select { |fp| fp.is_a?(Regexp) } regexps.any? { |fp| fp.match?(artifact) } end # # Get analyzer class # # @param [String] key # # @return [Class] analyzer class # def get_analyzer_class(key) raise ArgumentError, "#{key} is not supported" unless Mihari.analyzer_to_class.key?(key) Mihari.analyzer_to_class[key] end # # @return [Array] # def analyzers @analyzers ||= queries.deep_dup.map do |params| name = params.delete(:analyzer) klass = get_analyzer_class(name) klass.from_params(params).tap do |analyzer| analyzer.validate_configuration! end end end def parallel_analyzers analyzers.select(&:parallel?) end def serial_analyzers analyzers.reject(&:parallel?) end # @return [Array>, Dry::Monads::Result::Failure>] def analyzer_results parallel_results = Parallel.map(parallel_analyzers, &:result) serial_results = serial_analyzers.map(&:result) parallel_results + serial_results end # # Get emitter class # # @param [String] key # # @return [Class] emitter class # def get_emitter_class(key) raise ArgumentError, "#{key} is not supported" unless Mihari.emitter_to_class.key?(key) Mihari.emitter_to_class[key] end # # @return [Array] # def emitters @emitters ||= data[:emitters].deep_dup.map do |params| name = params.delete(:emitter) options = params.delete(:options) klass = get_emitter_class(name) klass.new(rule: self, options: options, **params).tap do |emitter| emitter.validate_configuration! end end end def parallel_emitters emitters.select(&:parallel?) end def serial_emitters emitters.reject(&:parallel?) end # # Get enricher class # # @param [String] key # # @return [Class] enricher class # def get_enricher_class(key) raise ArgumentError, "#{key} is not supported" unless Mihari.enricher_to_class.key?(key) Mihari.enricher_to_class[key] end # # @return [Array] enrichers # def enrichers @enrichers ||= data[:enrichers].deep_dup.map do |params| name = params.delete(:enricher) options = params.delete(:options) klass = get_enricher_class(name) klass.new(options: options, **params) end end def parallel_enrichers enrichers.select(&:parallel?) end def serial_enrichers enrichers.reject(&:parallel?) end # # Validate the data format # def validate! contract = Schemas::RuleContract.new result = contract.call(data) @data = result.to_h @errors = result.errors raise ValidationError.new("Validation failed", errors) if errors? end end end