Skip to content

Commit

Permalink
Merge pull request #1072 from ninoseki/yeti
Browse files Browse the repository at this point in the history
feat: support Yeti
  • Loading branch information
ninoseki authored Mar 20, 2024
2 parents 16d007a + c73511f commit fb963c7
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 6 deletions.
3 changes: 2 additions & 1 deletion docs/emitters/index.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
21 changes: 21 additions & 0 deletions docs/emitters/yeti.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions lib/mihari.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/mihari/clients/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions lib/mihari/clients/yeti.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions lib/mihari/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]

Expand Down
4 changes: 1 addition & 3 deletions lib/mihari/data_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
107 changes: 107 additions & 0 deletions lib/mihari/emitters/yeti.rb
Original file line number Diff line number Diff line change
@@ -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<Mihari::Models::Artifact>]
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<Mihari::Models::Artifact>] 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
7 changes: 7 additions & 0 deletions lib/mihari/schemas/emitter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions spec/emitters/yeti_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit fb963c7

Please sign in to comment.