From c73511f7dc90923caa85ad95c2899366381aeded Mon Sep 17 00:00:00 2001 From: Manabu Niseki Date: Wed, 20 Mar 2024 17:24:08 +0900 Subject: [PATCH] feat: support Yeti --- docs/emitters/index.md | 3 +- docs/emitters/yeti.md | 21 +++++++ lib/mihari.rb | 2 + lib/mihari/clients/base.rb | 5 +- lib/mihari/clients/yeti.rb | 38 ++++++++++++ lib/mihari/config.rb | 8 +++ lib/mihari/data_type.rb | 4 +- lib/mihari/emitters/yeti.rb | 107 ++++++++++++++++++++++++++++++++++ lib/mihari/schemas/emitter.rb | 7 +++ spec/emitters/yeti_spec.rb | 47 +++++++++++++++ 10 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 docs/emitters/yeti.md create mode 100644 lib/mihari/clients/yeti.rb create mode 100644 lib/mihari/emitters/yeti.rb create mode 100644 spec/emitters/yeti_spec.rb diff --git a/docs/emitters/index.md b/docs/emitters/index.md index db2fccc0f..e74654ee8 100644 --- a/docs/emitters/index.md +++ b/docs/emitters/index.md @@ -1,10 +1,11 @@ # Emitters - [Database](database.md) -- [TheHive](hive.md) - [MISP](misp.md) - [Slack](slack.md) +- [TheHive](hive.md) - [Webhook](webhook.md) +- [Yeti](yeti.md) ## Options diff --git a/docs/emitters/yeti.md b/docs/emitters/yeti.md new file mode 100644 index 000000000..4182a71ef --- /dev/null +++ b/docs/emitters/yeti.md @@ -0,0 +1,21 @@ +# Yeti + +- [https://yeti-platform.io/](https://yeti-platform.io/) + +This emitter creates observables on Yeti. + +```yaml +emitter: yeti +url: ... +api_key: ... +``` + +## Components + +### URL + +`url` (`string`) is a Yeti URL. Optional. Configurable via `YETI_URL` environment variable. + +### API Key + +`api_key` (`string`) is an API key. Optional. Configurable via `YETI_API_KEY` environment variable. diff --git a/lib/mihari.rb b/lib/mihari.rb index d693d56d9..f932d96f6 100644 --- a/lib/mihari.rb +++ b/lib/mihari.rb @@ -219,6 +219,7 @@ def initialize_sentry require "mihari/emitters/slack" require "mihari/emitters/the_hive" require "mihari/emitters/webhook" +require "mihari/emitters/yeti" # Clients require "mihari/clients/base" @@ -245,6 +246,7 @@ def initialize_sentry require "mihari/clients/urlscan" require "mihari/clients/virustotal" require "mihari/clients/whois" +require "mihari/clients/yeti" require "mihari/clients/zoomeye" # Analyzers diff --git a/lib/mihari/clients/base.rb b/lib/mihari/clients/base.rb index 5e942ffc6..10683ae73 100644 --- a/lib/mihari/clients/base.rb +++ b/lib/mihari/clients/base.rb @@ -87,11 +87,12 @@ def post(path, json: {}) # # @param [String] path # @param [Hash, nil] json + # @param [Hash, nil] headers # # @return [Hash] # - def post_json(path, json: {}) - res = http.post(url_for(path), json:) + def post_json(path, json: {}, headers: nil) + res = http.post(url_for(path), json:, headers: headers || {}) JSON.parse res.body.to_s end end diff --git a/lib/mihari/clients/yeti.rb b/lib/mihari/clients/yeti.rb new file mode 100644 index 000000000..360b36c51 --- /dev/null +++ b/lib/mihari/clients/yeti.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mihari + module Clients + # + # Yeti API client + # + class Yeti < Base + # + # @param [String] base_url + # @param [String, nil] api_key + # @param [Hash] headers + # @param [Integer, nil] timeout + # + def initialize(base_url, api_key:, headers: {}, timeout: nil) + raise(ArgumentError, "api_key is required") unless api_key + + headers["x-yeti-apikey"] = api_key + super(base_url, headers:, timeout:) + end + + def get_token + res = post_json("/api/v2/auth/api-token") + res["access_token"] + end + + # + # @param [Hash] json + # + # @return [Hash] + # + def create_observables(json) + token = get_token + post_json("/api/v2/observables/bulk", json:, headers: {authorization: "Bearer #{token}"}) + end + end + end +end diff --git a/lib/mihari/config.rb b/lib/mihari/config.rb index 7244510ae..8d9c10b9b 100644 --- a/lib/mihari/config.rb +++ b/lib/mihari/config.rb @@ -34,6 +34,8 @@ class Config < Anyway::Config thehive_url: nil, urlscan_api_key: nil, virustotal_api_key: nil, + yeti_api_key: nil, + yeti_url: nil, zoomeye_api_key: nil, # sidekiq sidekiq_redis_url: nil, @@ -123,6 +125,12 @@ class Config < Anyway::Config # @!attribute [r] virustotal_api_key # @return [String, nil] + # @!attribute [r] yeti_url + # @return [String, nil] + + # @!attribute [r] yeti_api_key + # @return [String, nil] + # @!attribute [r] zoomeye_api_key # @return [String, nil] diff --git a/lib/mihari/data_type.rb b/lib/mihari/data_type.rb index 3cee13a73..d74f471da 100644 --- a/lib/mihari/data_type.rb +++ b/lib/mihari/data_type.rb @@ -26,9 +26,7 @@ def hash? # @return [Boolean] def ip? - Try[IPAddr::InvalidAddressError] do - IPAddr.new(data).to_s == data - end.recover { false }.value! + Try[IPAddr::InvalidAddressError] { IPAddr.new(data).to_s == data }.recover { false }.value! end # @return [Boolean] diff --git a/lib/mihari/emitters/yeti.rb b/lib/mihari/emitters/yeti.rb new file mode 100644 index 000000000..e09cdf4a1 --- /dev/null +++ b/lib/mihari/emitters/yeti.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Mihari + module Emitters + class Yeti < Base + # @return [String, nil] + attr_reader :url + + # @return [String, nil] + attr_reader :api_key + + # @return [Array] + attr_accessor :artifacts + + # + # @param [Mihari::Rule] rule + # @param [Hash, nil] options + # @param [Hash] params + # + def initialize(rule:, options: nil, **params) + super(rule:, options:) + + @url = params[:url] || Mihari.config.yeti_url + @api_key = params[:api_key] || Mihari.config.yeti_api_key + + @artifacts = [] + end + + # + # @return [Boolean] + # + def configured? + api_key? && url? + end + + # + # Create a Hive alert + # + # @param [Array] artifacts + # + def call(artifacts) + return if artifacts.empty? + + @artifacts = artifacts + + client.create_observables({observables:}) + end + + # + # @return [String] + # + def target + URI(url).host || "N/A" + end + + private + + def client + Clients::Yeti.new(url, api_key:, timeout:) + end + + # + # Check whether a URL is set or not + # + # @return [Boolean] + # + def url? + !url.nil? + end + + def acceptable_artifacts + artifacts.reject { |artifact| artifact.data_type == "mail" } + end + + # + # @param [Mihari::Models::Artifact] artifact + # + # @return [Hash] + # + def artifact_to_observable(artifact) + convert_table = { + domain: "hostname", + ip: "ipv4" + } + + type = lambda do + detailed_type = DataType.detailed_type(artifact.data) + convert_table[detailed_type.to_sym] || detailed_type || artifact.data_type + end.call + + { + tags:, + type:, + value: artifact.data + } + end + + def tags + @tags ||= rule.tags.map(&:name) + end + + def observables + acceptable_artifacts.map { |artifact| artifact_to_observable(artifact) } + end + end + end +end diff --git a/lib/mihari/schemas/emitter.rb b/lib/mihari/schemas/emitter.rb index 5fc467ba6..02848e4a2 100644 --- a/lib/mihari/schemas/emitter.rb +++ b/lib/mihari/schemas/emitter.rb @@ -29,6 +29,13 @@ module Emitters optional(:options).hash(EmitterOptions) end + Yeti = Dry::Schema.Params do + required(:emitter).value(Types::String.enum(*Mihari::Emitters::Yeti.keys)) + optional(:url).filled(:string) + optional(:api_key).filled(:string) + optional(:options).hash(EmitterOptions) + end + Slack = Dry::Schema.Params do required(:emitter).value(Types::String.enum(*Mihari::Emitters::Slack.keys)) optional(:webhook_url).filled(:string) diff --git a/spec/emitters/yeti_spec.rb b/spec/emitters/yeti_spec.rb new file mode 100644 index 000000000..fbdeeadd8 --- /dev/null +++ b/spec/emitters/yeti_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.describe Mihari::Emitters::Yeti do + subject(:emitter) { described_class.new(rule:) } + + include_context "with mocked logger" + + let_it_be(:rule) { Mihari::Rule.from_model FactoryBot.create(:rule) } + let!(:artifacts) { [Mihari::Models::Artifact.new(data: "1.1.1.1")] } + + describe "#configured?" do + it do + expect(emitter.configured?).to be(true) + end + + context "without YETI_URL" do + before do + allow(Mihari.config).to receive(:yeti_url).and_return(nil) + end + + it do + expect(emitter.configured?).to be(false) + end + end + end + + describe "#call" do + let!(:mock_client) { instance_double("client") } + let!(:mocked_emitter) { described_class.new(rule:) } + + before do + allow(mocked_emitter).to receive(:client).and_return(mock_client) + allow(mock_client).to receive(:create_observables) + end + + it do + mocked_emitter.call artifacts + expect(mock_client).to have_received(:create_observables) + end + end + + describe "#target" do + it do + expect(emitter.target).to be_a(String) + end + end +end