mihari 0.17.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop.yml +155 -0
  4. data/.travis.yml +7 -1
  5. data/Gemfile +2 -0
  6. data/README.md +41 -72
  7. data/config/pre_commit.yml +3 -0
  8. data/docker/Dockerfile +1 -1
  9. data/lib/mihari.rb +12 -8
  10. data/lib/mihari/alert_viewer.rb +16 -34
  11. data/lib/mihari/analyzers/base.rb +7 -19
  12. data/lib/mihari/analyzers/basic.rb +3 -1
  13. data/lib/mihari/analyzers/binaryedge.rb +3 -3
  14. data/lib/mihari/analyzers/censys.rb +2 -2
  15. data/lib/mihari/analyzers/circl.rb +2 -2
  16. data/lib/mihari/analyzers/onyphe.rb +3 -3
  17. data/lib/mihari/analyzers/passivetotal.rb +2 -2
  18. data/lib/mihari/analyzers/pulsedive.rb +2 -2
  19. data/lib/mihari/analyzers/securitytrails.rb +2 -2
  20. data/lib/mihari/analyzers/securitytrails_domain_feed.rb +2 -2
  21. data/lib/mihari/analyzers/shodan.rb +2 -2
  22. data/lib/mihari/analyzers/virustotal.rb +2 -2
  23. data/lib/mihari/analyzers/zoomeye.rb +2 -2
  24. data/lib/mihari/cli.rb +13 -4
  25. data/lib/mihari/config.rb +68 -2
  26. data/lib/mihari/configurable.rb +1 -1
  27. data/lib/mihari/database.rb +68 -0
  28. data/lib/mihari/emitters/base.rb +1 -1
  29. data/lib/mihari/emitters/database.rb +29 -0
  30. data/lib/mihari/emitters/misp.rb +8 -1
  31. data/lib/mihari/emitters/slack.rb +4 -2
  32. data/lib/mihari/emitters/stdout.rb +2 -1
  33. data/lib/mihari/emitters/the_hive.rb +28 -14
  34. data/lib/mihari/models/alert.rb +11 -0
  35. data/lib/mihari/models/artifact.rb +27 -0
  36. data/lib/mihari/models/tag.rb +10 -0
  37. data/lib/mihari/models/tagging.rb +10 -0
  38. data/lib/mihari/notifiers/slack.rb +7 -4
  39. data/lib/mihari/serializers/alert.rb +12 -0
  40. data/lib/mihari/serializers/artifact.rb +9 -0
  41. data/lib/mihari/serializers/tag.rb +9 -0
  42. data/lib/mihari/slack_monkeypatch.rb +16 -0
  43. data/lib/mihari/status.rb +1 -1
  44. data/lib/mihari/type_checker.rb +1 -1
  45. data/lib/mihari/version.rb +1 -1
  46. data/mihari.gemspec +13 -6
  47. metadata +140 -36
  48. data/lib/mihari/artifact.rb +0 -36
  49. data/lib/mihari/cache.rb +0 -35
  50. data/lib/mihari/the_hive.rb +0 -42
  51. data/lib/mihari/the_hive/alert.rb +0 -25
  52. data/lib/mihari/the_hive/artifact.rb +0 -33
  53. data/lib/mihari/the_hive/base.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a2088860e89b6cfa7ead2c47b1589ee19d6cb388051e1f5f0325c3b8eab9184
4
- data.tar.gz: de9a0261e8ede8ff2bd6f93f236fd1d2eee2f8dfa7d8125e9ff2ad2b0c2089ba
3
+ metadata.gz: 5c43df888d661331830b74ecbf097f2bf7f0450850fca0ad8bfe86d58fe5d32f
4
+ data.tar.gz: 123d3a13867550f57557472e63819b1bc8a70fd80bb0ef3fa0543d9414fc84a8
5
5
  SHA512:
6
- metadata.gz: 12d2823b24f99989e44a8001e50b20222a1cfea8905a2c375365ff27eb4603f0e7aaf36fcdb53aa350d30ca5774622b22f85e2c2aa049d219ea89ec154783753
7
- data.tar.gz: 9fdc2753b2d480a453867e83d9c11bebf4f376aaaddfd55696569ab2c41af6386c55e2f4d08ad5976439787676fca32cd2dba13ce80c2dbc65ff0dbe693bf8a0
6
+ metadata.gz: 96693d5a7ca81b7a1f6834ffedb7ad9897dba6fe510dc632c204a2497595ae67c6bf7c70895bccc5738d6d83369cafc7f309962e6e29a941a2ccb5f5fc84b68a
7
+ data.tar.gz: 9b4d2ccb878b2aec9b82ac158b5da5bea76653b443c3d53c5d4146a4e3900f400680d690cb0ab6becb526f827d1cb482d663719625f10ee6011e598411c29b67
data/.gitignore CHANGED
@@ -54,3 +54,6 @@ Gemfile.lock
54
54
 
55
55
  # solargraph
56
56
  .solargraph.yml
57
+
58
+ # SQLite
59
+ *.db
@@ -0,0 +1,155 @@
1
+ # Relaxed.Ruby.Style
2
+ ## Version 2.5
3
+
4
+ require:
5
+ - rubocop-performance
6
+
7
+ Style/Alias:
8
+ Enabled: false
9
+ StyleGuide: https://relaxed.ruby.style/#stylealias
10
+
11
+ Style/AsciiComments:
12
+ Enabled: false
13
+ StyleGuide: https://relaxed.ruby.style/#styleasciicomments
14
+
15
+ Style/BeginBlock:
16
+ Enabled: false
17
+ StyleGuide: https://relaxed.ruby.style/#stylebeginblock
18
+
19
+ Style/BlockDelimiters:
20
+ Enabled: false
21
+ StyleGuide: https://relaxed.ruby.style/#styleblockdelimiters
22
+
23
+ Style/CommentAnnotation:
24
+ Enabled: false
25
+ StyleGuide: https://relaxed.ruby.style/#stylecommentannotation
26
+
27
+ Style/Documentation:
28
+ Enabled: false
29
+ StyleGuide: https://relaxed.ruby.style/#styledocumentation
30
+
31
+ Layout/DotPosition:
32
+ Enabled: false
33
+ StyleGuide: https://relaxed.ruby.style/#layoutdotposition
34
+
35
+ Style/DoubleNegation:
36
+ Enabled: false
37
+ StyleGuide: https://relaxed.ruby.style/#styledoublenegation
38
+
39
+ Style/EndBlock:
40
+ Enabled: false
41
+ StyleGuide: https://relaxed.ruby.style/#styleendblock
42
+
43
+ Style/FormatString:
44
+ Enabled: false
45
+ StyleGuide: https://relaxed.ruby.style/#styleformatstring
46
+
47
+ Style/IfUnlessModifier:
48
+ Enabled: false
49
+ StyleGuide: https://relaxed.ruby.style/#styleifunlessmodifier
50
+
51
+ Style/Lambda:
52
+ Enabled: false
53
+ StyleGuide: https://relaxed.ruby.style/#stylelambda
54
+
55
+ Style/ModuleFunction:
56
+ Enabled: false
57
+ StyleGuide: https://relaxed.ruby.style/#stylemodulefunction
58
+
59
+ Style/MultilineBlockChain:
60
+ Enabled: false
61
+ StyleGuide: https://relaxed.ruby.style/#stylemultilineblockchain
62
+
63
+ Style/NegatedIf:
64
+ Enabled: false
65
+ StyleGuide: https://relaxed.ruby.style/#stylenegatedif
66
+
67
+ Style/NegatedWhile:
68
+ Enabled: false
69
+ StyleGuide: https://relaxed.ruby.style/#stylenegatedwhile
70
+
71
+ Style/NumericPredicate:
72
+ Enabled: false
73
+ StyleGuide: https://relaxed.ruby.style/#stylenumericpredicate
74
+
75
+ Style/ParallelAssignment:
76
+ Enabled: false
77
+ StyleGuide: https://relaxed.ruby.style/#styleparallelassignment
78
+
79
+ Style/PercentLiteralDelimiters:
80
+ Enabled: false
81
+ StyleGuide: https://relaxed.ruby.style/#stylepercentliteraldelimiters
82
+
83
+ Style/PerlBackrefs:
84
+ Enabled: false
85
+ StyleGuide: https://relaxed.ruby.style/#styleperlbackrefs
86
+
87
+ Style/Semicolon:
88
+ Enabled: false
89
+ StyleGuide: https://relaxed.ruby.style/#stylesemicolon
90
+
91
+ Style/SignalException:
92
+ Enabled: false
93
+ StyleGuide: https://relaxed.ruby.style/#stylesignalexception
94
+
95
+ Style/SingleLineBlockParams:
96
+ Enabled: false
97
+ StyleGuide: https://relaxed.ruby.style/#stylesinglelineblockparams
98
+
99
+ Style/SingleLineMethods:
100
+ Enabled: false
101
+ StyleGuide: https://relaxed.ruby.style/#stylesinglelinemethods
102
+
103
+ Layout/SpaceBeforeBlockBraces:
104
+ Enabled: false
105
+ StyleGuide: https://relaxed.ruby.style/#layoutspacebeforeblockbraces
106
+
107
+ Layout/SpaceInsideParens:
108
+ Enabled: false
109
+ StyleGuide: https://relaxed.ruby.style/#layoutspaceinsideparens
110
+
111
+ Style/SpecialGlobalVars:
112
+ Enabled: false
113
+ StyleGuide: https://relaxed.ruby.style/#stylespecialglobalvars
114
+
115
+ Style/StringLiterals:
116
+ Enabled: false
117
+ StyleGuide: https://relaxed.ruby.style/#stylestringliterals
118
+
119
+ Style/TrailingCommaInArguments:
120
+ Enabled: false
121
+ StyleGuide: https://relaxed.ruby.style/#styletrailingcommainarguments
122
+
123
+ Style/TrailingCommaInArrayLiteral:
124
+ Enabled: false
125
+ StyleGuide: https://relaxed.ruby.style/#styletrailingcommainarrayliteral
126
+
127
+ Style/TrailingCommaInHashLiteral:
128
+ Enabled: false
129
+ StyleGuide: https://relaxed.ruby.style/#styletrailingcommainhashliteral
130
+
131
+ Style/SymbolArray:
132
+ Enabled: false
133
+ StyleGuide: http://relaxed.ruby.style/#stylesymbolarray
134
+
135
+ Style/WhileUntilModifier:
136
+ Enabled: false
137
+ StyleGuide: https://relaxed.ruby.style/#stylewhileuntilmodifier
138
+
139
+ Style/WordArray:
140
+ Enabled: false
141
+ StyleGuide: https://relaxed.ruby.style/#stylewordarray
142
+
143
+ Lint/AmbiguousRegexpLiteral:
144
+ Enabled: false
145
+ StyleGuide: https://relaxed.ruby.style/#lintambiguousregexpliteral
146
+
147
+ Lint/AssignmentInCondition:
148
+ Enabled: false
149
+ StyleGuide: https://relaxed.ruby.style/#lintassignmentincondition
150
+
151
+ Layout/LineLength:
152
+ Enabled: false
153
+
154
+ Metrics:
155
+ Enabled: false
@@ -1,7 +1,13 @@
1
1
  ---
2
- sudo: false
3
2
  language: ruby
4
3
  cache: bundler
4
+ services:
5
+ - postgresql
5
6
  rvm:
6
7
  - 2.6
8
+ - 2.7
9
+ env:
10
+ - DATABASE=":memory:"
11
+ - DATABASE="postgresql://postgres@0.0.0.0:5432/travis_ci_test"
7
12
  before_install: gem install bundler -v 2.1
13
+ before_script: psql -c 'create database travis_ci_test;' -U postgres
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source "https://rubygems.org"
2
4
 
3
5
  # Specify your gem's dependencies in mihari.gemspec
data/README.md CHANGED
@@ -10,19 +10,15 @@ Mihari is a helper to run queries & manage results continuously. Mihari can be u
10
10
 
11
11
  ## How it works
12
12
 
13
- - Mihari makes a query against Shodan, Censys, VirusTotal, SecurityTrails, etc. and extracts artifacts from the results.
14
- - Mihari checks whether [TheHive](https://thehive-project.org/) contains the artifacts or not.
13
+ - Mihari makes a query against Shodan, Censys, VirusTotal, SecurityTrails, etc. and extracts artifacts (IP addresses, domains, URLs and hashes) from the results.
14
+ - Mihari checks whether a DB (SQLite3 or PostgreSQL) contains the artifacts or not.
15
15
  - If it doesn't contain the artifacts:
16
- - Mihari creates an alert on TheHive.
16
+ - Mihari creates an alert on TheHive. (Optional)
17
17
  - Mihari sends a notification to Slack. (Optional)
18
18
  - Mihari creates an event on MISP. (Optional)
19
19
 
20
20
  ![img](https://github.com/ninoseki/mihari/raw/master/screenshots/eyecatch.png)
21
21
 
22
- Check this blog post for more details: [Continuous C2 hunting with Censys, Shodan, Onyphe and TheHive](https://hackmd.io/s/SkUaSrqoE).
23
-
24
- You can use mihari without TheHive but note that mihari depends on TheHive to manage artifacts. It means mihari might make duplications when without TheHive.
25
-
26
22
  ### Screenshots
27
23
 
28
24
  - TheHive alert example
@@ -37,6 +33,17 @@ You can use mihari without TheHive but note that mihari depends on TheHive to ma
37
33
 
38
34
  ![img](https://github.com/ninoseki/mihari/raw/master/screenshots/misp.png)
39
35
 
36
+ ## Requirements
37
+
38
+ - Ruby 2.6+
39
+ - SQLite3
40
+ - libpq
41
+
42
+ ```bash
43
+ # For Debian / Ubuntu
44
+ apt-get install sqlite3 libsqlite3-dev libpq-dev
45
+ ```
46
+
40
47
  ## Installation
41
48
 
42
49
  ```bash
@@ -156,49 +163,13 @@ mihari http_hash --html /tmp/index.html
156
163
 
157
164
  ```bash
158
165
  # Censys lookup for PANDA C2
159
- $ mihari censys '("PANDA" AND "SMAdmin" AND "layui")' --title "PANDA C2"
160
- {
161
- "title": "PANDA C2",
162
- "description": "query = (\"PANDA\" AND \"SMAdmin\" AND \"layui\")",
163
- "artifacts": [
164
- "154.223.165.223",
165
- "154.194.2.31",
166
- "45.114.127.119",
167
- "..."
168
- ],
169
- "tags": []
170
- }
166
+ mihari censys '("PANDA" AND "SMAdmin" AND "layui")' --title "PANDA C2"
171
167
 
172
168
  # VirusTotal passive DNS lookup of a FAKESPY host
173
- $ mihari virustotal "jppost-hi.top" --title "FAKESPY host passive DNS results"
174
- {
175
- "title": "FAKESPY host passive DNS results",
176
- "description": "indicator = jppost-hi.top",
177
- "artifacts": [
178
- "185.22.152.28",
179
- "192.236.200.44",
180
- "193.148.69.12",
181
- "..."
182
- ],
183
- "tags": []
184
- }
169
+ mihari virustotal "jppost-hi.top" --title "FAKESPY passive DNS"
185
170
 
186
171
  # You can pass a "defanged" indicator as an input
187
- $ mihari virustotal "jppost-hi[.]top" --title "FAKESPY host passive DNS results"
188
-
189
- # SecurityTrails domain feed lookup for finding (possibly) Apple phishing websites
190
- $ mihari securitytrails_domain_feed "apple-" --type new
191
- {
192
- "title": "SecurityTrails domain feed lookup",
193
- "description": "Regexp = /apple-/",
194
- "artifacts": [
195
- "apple-sign.online",
196
- "apple-log-in.com",
197
- "apple-locator-id.info",
198
- "..."
199
- ],
200
- "tags": []
201
- }
172
+ mihari virustotal "jppost-hi[.]top" --title "FAKESPY passive DNS"
202
173
  ```
203
174
 
204
175
  ### Import from JSON
@@ -229,28 +200,29 @@ The input is a JSON data should have `title`, `description` and `artifacts` key.
229
200
 
230
201
  Configuration can be done via environment variables or a YAML file.
231
202
 
232
- | Key | Desc. | Recommended or optional |
233
- |------------------------|--------------------------------|--------------------------------|
234
- | THEHIVE_API_ENDPOINT | TheHive URL | Recommended |
235
- | THEHIVE_API_KEY | TheHive API key | Recommended |
236
- | MISP_API_ENDPOINT | MISP URL | Optional |
237
- | MISP_API_KEY | MISP API key | Optional |
238
- | SLACK_WEBHOOK_URL | Slack Webhook URL | Optional |
239
- | SLACK_CHANNEL | Slack channel name | Optional (default: `#general`) |
240
- | BINARYEDGE_API_KEY | BinaryEdge API key | Optional |
241
- | CENSYS_ID | Censys API ID | Optional |
242
- | CENSYS_SECRET | Censys secret | Optional |
243
- | CIRCL_PASSIVE_PASSWORD | CIRCL passive DNS/SSL password | Optional |
244
- | CIRCL_PASSIVE_USERNAME | CIRCL passive DNS/SSL username | Optional |
245
- | ONYPHE_API_KEY | Onyphe API key | Optional |
246
- | PASSIVETOTAL_API_KEY | PassiveTotal API key | Optional |
247
- | PASSIVETOTAL_USERNAME | PassiveTotal username | Optional |
248
- | PULSEDIVE_API_KEY | Pulsedive API key | Optional |
249
- | SECURITYTRAILS_API_KEY | SecurityTrails API key | Optional |
250
- | SHODAN_API_KEY | Shodan API key | Optional |
251
- | VIRUSTOTAL_API_KEY | VirusTotal API key | Optional |
252
- | ZOOMEYE_USERNAMME | ZoomEye username | Optional |
253
- | ZOOMEYE_PASSWORD | ZoomEye password | Optional |
203
+ | Key | Description | Default |
204
+ |------------------------|-------------------------------------------------------------------------------------------------|-------------|
205
+ | DATABASE | A path to the SQLite database or a DB URL (e.g. `postgres://postgres:pass@db.host:5432/somedb`) | `mihari.db` |
206
+ | BINARYEDGE_API_KEY | BinaryEdge API key | |
207
+ | CENSYS_ID | Censys API ID | |
208
+ | CENSYS_SECRET | Censys secret | |
209
+ | CIRCL_PASSIVE_PASSWORD | CIRCL passive DNS/SSL password | |
210
+ | CIRCL_PASSIVE_USERNAME | CIRCL passive DNS/SSL username | |
211
+ | MISP_API_ENDPOINT | MISP URL | |
212
+ | MISP_API_KEY | MISP API key | |
213
+ | ONYPHE_API_KEY | Onyphe API key | |
214
+ | PASSIVETOTAL_API_KEY | PassiveTotal API key | |
215
+ | PASSIVETOTAL_USERNAME | PassiveTotal username | |
216
+ | PULSEDIVE_API_KEY | Pulsedive API key | |
217
+ | SECURITYTRAILS_API_KEY | SecurityTrails API key | |
218
+ | SHODAN_API_KEY | Shodan API key | |
219
+ | SLACK_CHANNEL | Slack channel name | `#general` |
220
+ | SLACK_WEBHOOK_URL | Slack Webhook URL | |
221
+ | THEHIVE_API_ENDPOINT | TheHive URL | |
222
+ | THEHIVE_API_KEY | TheHive API key | |
223
+ | VIRUSTOTAL_API_KEY | VirusTotal API key | |
224
+ | ZOOMEYE_PASSWORD | ZoomEye password | |
225
+ | ZOOMEYE_USERNAMME | ZoomEye username | |
254
226
 
255
227
  Instead of using environment variables, you can use a YAML file for configuration.
256
228
 
@@ -261,6 +233,7 @@ mihari virustotal 1.1.1.1 --config /path/to/yaml.yml
261
233
  The YAML file should be a YAML hash like below:
262
234
 
263
235
  ```yaml
236
+ database: /tmp/mihari.db
264
237
  thehive_api_endpoint: https://localhost
265
238
  thehive_api_key: foo
266
239
  virustotal_api_key: foo
@@ -314,10 +287,6 @@ example.run
314
287
 
315
288
  See `/examples` for more.
316
289
 
317
- ## Caching
318
-
319
- Mihari caches execution results in `/tmp/mihari` and the default cache duration is 7 days. If you want to clear the cache, please clear `/tmp/mihari`.
320
-
321
290
  ## Using it with Docker
322
291
 
323
292
  ```bash
@@ -0,0 +1,3 @@
1
+ ---
2
+ :checks_add:
3
+ - :rubocop
@@ -1,5 +1,5 @@
1
1
  FROM ruby:2.6-alpine3.10
2
- RUN apk --no-cache add git build-base ruby-dev \
2
+ RUN apk --no-cache add git build-base ruby-dev sqlite-dev postgresql-dev \
3
3
  && cd /tmp/ \
4
4
  && git clone https://github.com/ninoseki/mihari.git \
5
5
  && cd mihari \
@@ -19,24 +19,27 @@ module Mihari
19
19
  end
20
20
 
21
21
  require "mihari/version"
22
-
23
22
  require "mihari/errors"
24
23
 
25
- require "mihari/artifact"
26
- require "mihari/cache"
27
24
  require "mihari/config"
25
+
26
+ require "mihari/database"
28
27
  require "mihari/type_checker"
29
28
 
29
+ require "mihari/models/alert"
30
+ require "mihari/models/artifact"
31
+ require "mihari/models/tag"
32
+ require "mihari/models/tagging"
33
+
34
+ require "mihari/serializers/alert"
35
+ require "mihari/serializers/artifact"
36
+ require "mihari/serializers/tag"
37
+
30
38
  require "mihari/html"
31
39
 
32
40
  require "mihari/configurable"
33
41
  require "mihari/retriable"
34
42
 
35
- require "mihari/the_hive/base"
36
- require "mihari/the_hive/alert"
37
- require "mihari/the_hive/artifact"
38
- require "mihari/the_hive"
39
-
40
43
  require "mihari/analyzers/base"
41
44
  require "mihari/analyzers/basic"
42
45
 
@@ -68,6 +71,7 @@ require "mihari/notifiers/slack"
68
71
  require "mihari/notifiers/exception_notifier"
69
72
 
70
73
  require "mihari/emitters/base"
74
+ require "mihari/emitters/database"
71
75
  require "mihari/emitters/misp"
72
76
  require "mihari/emitters/slack"
73
77
  require "mihari/emitters/stdout"
@@ -2,40 +2,22 @@
2
2
 
3
3
  module Mihari
4
4
  class AlertViewer
5
- attr_reader :limit
6
- attr_reader :the_hive
7
-
8
- ALERT_KEYS = %w(title description artifacts tags createdAt status).freeze
9
-
10
- def initialize(limit: 5)
11
- @limit = limit
12
- validate_limit
13
-
14
- @the_hive = TheHive.new
15
- raise Error, "Cannot connect to the TheHive instance" unless the_hive.valid?
16
- end
17
-
18
- def list
19
- range = limit == "all" ? "all" : "0-#{limit}"
20
- alerts = the_hive.alert.list(range: range)
21
- alerts.map { |alert| convert alert }
22
- end
23
-
24
- private
25
-
26
- def validate_limit
27
- return true if limit == "all"
28
-
29
- raise ArgumentError, "limit should be bigger than zero" unless limit.to_i.positive?
30
- end
31
-
32
- def convert(alert)
33
- attributes = alert.select { |k, _v| ALERT_KEYS.include? k }
34
- attributes["createdAt"] = Time.at(attributes["createdAt"] / 1000).to_s
35
- attributes["artifacts"] = (attributes.dig("artifacts") || []).map do |artifact|
36
- artifact.dig("data")
37
- end.sort
38
- attributes
5
+ def list(title: nil, source: nil, tag: nil, limit: 5)
6
+ limit = limit.to_i
7
+ raise ArgumentError, "limit should be bigger than zero" unless limit.positive?
8
+
9
+ relation = Alert.includes(:tags, :artifacts)
10
+ relation = relation.where(title: title) if title
11
+ relation = relation.where(source: source) if source
12
+ relation = relation.where(tags: { name: tag } ) if tag
13
+
14
+ alerts = relation.limit(limit).order(id: :desc)
15
+ alerts.map do |alert|
16
+ json = AlertSerializer.new(alert).as_json
17
+ json[:artifacts] = (json.dig(:artifacts) || []).map { |artifact_| artifact_.dig(:data) }
18
+ json[:tags] = (json.dig(:tags) || []).map { |tag_| tag_.dig(:name) }
19
+ json
20
+ end
39
21
  end
40
22
  end
41
23
  end