mihari 0.17.5 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop.yml +155 -0
  4. data/.travis.yml +1 -0
  5. data/Gemfile +2 -0
  6. data/README.md +30 -72
  7. data/config/pre_commit.yml +3 -0
  8. data/lib/mihari.rb +12 -8
  9. data/lib/mihari/alert_viewer.rb +6 -28
  10. data/lib/mihari/analyzers/base.rb +7 -19
  11. data/lib/mihari/analyzers/basic.rb +3 -1
  12. data/lib/mihari/analyzers/binaryedge.rb +2 -2
  13. data/lib/mihari/analyzers/censys.rb +2 -2
  14. data/lib/mihari/analyzers/circl.rb +2 -2
  15. data/lib/mihari/analyzers/onyphe.rb +3 -3
  16. data/lib/mihari/analyzers/passivetotal.rb +2 -2
  17. data/lib/mihari/analyzers/pulsedive.rb +2 -2
  18. data/lib/mihari/analyzers/securitytrails.rb +2 -2
  19. data/lib/mihari/analyzers/securitytrails_domain_feed.rb +2 -2
  20. data/lib/mihari/analyzers/shodan.rb +2 -2
  21. data/lib/mihari/analyzers/virustotal.rb +2 -2
  22. data/lib/mihari/analyzers/zoomeye.rb +2 -2
  23. data/lib/mihari/cli.rb +2 -2
  24. data/lib/mihari/config.rb +68 -2
  25. data/lib/mihari/configurable.rb +1 -1
  26. data/lib/mihari/database.rb +45 -0
  27. data/lib/mihari/emitters/base.rb +1 -1
  28. data/lib/mihari/emitters/misp.rb +8 -1
  29. data/lib/mihari/emitters/slack.rb +2 -2
  30. data/lib/mihari/emitters/sqlite.rb +29 -0
  31. data/lib/mihari/emitters/stdout.rb +2 -1
  32. data/lib/mihari/emitters/the_hive.rb +28 -14
  33. data/lib/mihari/models/alert.rb +11 -0
  34. data/lib/mihari/models/artifact.rb +27 -0
  35. data/lib/mihari/models/tag.rb +10 -0
  36. data/lib/mihari/models/tagging.rb +10 -0
  37. data/lib/mihari/notifiers/slack.rb +4 -4
  38. data/lib/mihari/serializers/alert.rb +12 -0
  39. data/lib/mihari/serializers/artifact.rb +9 -0
  40. data/lib/mihari/serializers/tag.rb +9 -0
  41. data/lib/mihari/status.rb +1 -1
  42. data/lib/mihari/type_checker.rb +1 -1
  43. data/lib/mihari/version.rb +1 -1
  44. data/mihari.gemspec +11 -5
  45. metadata +120 -31
  46. data/lib/mihari/artifact.rb +0 -36
  47. data/lib/mihari/cache.rb +0 -35
  48. data/lib/mihari/the_hive.rb +0 -42
  49. data/lib/mihari/the_hive/alert.rb +0 -25
  50. data/lib/mihari/the_hive/artifact.rb +0 -33
  51. data/lib/mihari/the_hive/base.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 004a0d8f2ddeda5059f6748657b786b9470a290eb4d593ff0bb0632ebb495d3d
4
- data.tar.gz: c4f51acdb2dc76e52a445e382ce3c15b215b29f9abd860ca1c05fe814b24bf96
3
+ metadata.gz: '099c3dece3c31a745979b619c361d3bcff09e67abb3214c32747a935d57be963'
4
+ data.tar.gz: 1f777f2ca61721716e32e85846817a5183d11063573813adec757919bb27f457
5
5
  SHA512:
6
- metadata.gz: f3d1b8959e726240a7257f999347ff2fc81b9bf359948dfc9f09a9edad66d76225f8a4e16b105d1bda2ee5675100e13705fdb448dc6986493234280f849c4637
7
- data.tar.gz: 1b6dd5276812e5ba6f6c7997cb921acd261dbcced5af3599253b3995ced1450a8a998e8bdd88155ebe44ce3e724fb71da6e59fb72c2c6ed12222f1e1efd07dcc
6
+ metadata.gz: b8e0babf78935a90d1d4d39493ed7db77d498697a1ea8d2bbdf3277745544a0f7a59de6dc444e93065f66c339b9c5208df408748194cb490ffe3901681a9aa0b
7
+ data.tar.gz: 0b430d270f34f1fae64f384b672a4270500407e725f0f8f99bba278b134d74196fda894b4ed425ed21d363583da93e40648dce77b9ff39a9b0da07c7f5a7a5d2
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
@@ -4,4 +4,5 @@ language: ruby
4
4
  cache: bundler
5
5
  rvm:
6
6
  - 2.6
7
+ - 2.7
7
8
  before_install: gem install bundler -v 2.1
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) 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
@@ -156,49 +152,13 @@ mihari http_hash --html /tmp/index.html
156
152
 
157
153
  ```bash
158
154
  # 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
- }
155
+ mihari censys '("PANDA" AND "SMAdmin" AND "layui")' --title "PANDA C2"
171
156
 
172
157
  # 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
- }
158
+ mihari virustotal "jppost-hi.top" --title "FAKESPY passive DNS"
185
159
 
186
160
  # 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
- }
161
+ mihari virustotal "jppost-hi[.]top" --title "FAKESPY passive DNS"
202
162
  ```
203
163
 
204
164
  ### Import from JSON
@@ -229,28 +189,29 @@ The input is a JSON data should have `title`, `description` and `artifacts` key.
229
189
 
230
190
  Configuration can be done via environment variables or a YAML file.
231
191
 
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 |
192
+ | Key | Description | Default |
193
+ |------------------------|--------------------------------|-------------|
194
+ | DATABASE | A path to the SQLite database | `mihari.db` |
195
+ | BINARYEDGE_API_KEY | BinaryEdge API key | |
196
+ | CENSYS_ID | Censys API ID | |
197
+ | CENSYS_SECRET | Censys secret | |
198
+ | CIRCL_PASSIVE_PASSWORD | CIRCL passive DNS/SSL password | |
199
+ | CIRCL_PASSIVE_USERNAME | CIRCL passive DNS/SSL username | |
200
+ | MISP_API_ENDPOINT | MISP URL | |
201
+ | MISP_API_KEY | MISP API key | |
202
+ | ONYPHE_API_KEY | Onyphe API key | |
203
+ | PASSIVETOTAL_API_KEY | PassiveTotal API key | |
204
+ | PASSIVETOTAL_USERNAME | PassiveTotal username | |
205
+ | PULSEDIVE_API_KEY | Pulsedive API key | |
206
+ | SECURITYTRAILS_API_KEY | SecurityTrails API key | |
207
+ | SHODAN_API_KEY | Shodan API key | |
208
+ | SLACK_CHANNEL | Slack channel name | `#general` |
209
+ | SLACK_WEBHOOK_URL | Slack Webhook URL | |
210
+ | THEHIVE_API_ENDPOINT | TheHive URL | |
211
+ | THEHIVE_API_KEY | TheHive API key | |
212
+ | VIRUSTOTAL_API_KEY | VirusTotal API key | |
213
+ | ZOOMEYE_PASSWORD | ZoomEye password | |
214
+ | ZOOMEYE_USERNAMME | ZoomEye username | |
254
215
 
255
216
  Instead of using environment variables, you can use a YAML file for configuration.
256
217
 
@@ -261,6 +222,7 @@ mihari virustotal 1.1.1.1 --config /path/to/yaml.yml
261
222
  The YAML file should be a YAML hash like below:
262
223
 
263
224
  ```yaml
225
+ database: /tmp/mihari.db
264
226
  thehive_api_endpoint: https://localhost
265
227
  thehive_api_key: foo
266
228
  virustotal_api_key: foo
@@ -314,10 +276,6 @@ example.run
314
276
 
315
277
  See `/examples` for more.
316
278
 
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
279
  ## Using it with Docker
322
280
 
323
281
  ```bash
@@ -0,0 +1,3 @@
1
+ ---
2
+ :checks_add:
3
+ - :rubocop
@@ -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
 
@@ -70,6 +73,7 @@ require "mihari/notifiers/exception_notifier"
70
73
  require "mihari/emitters/base"
71
74
  require "mihari/emitters/misp"
72
75
  require "mihari/emitters/slack"
76
+ require "mihari/emitters/sqlite"
73
77
  require "mihari/emitters/stdout"
74
78
  require "mihari/emitters/the_hive"
75
79
 
@@ -3,39 +3,17 @@
3
3
  module Mihari
4
4
  class AlertViewer
5
5
  attr_reader :limit
6
- attr_reader :the_hive
7
-
8
- ALERT_KEYS = %w(title description artifacts tags createdAt status).freeze
9
6
 
10
7
  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?
8
+ @limit = limit.to_i
9
+ raise ArgumentError, "limit should be bigger than zero" unless @limit.positive?
16
10
  end
17
11
 
18
12
  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
13
+ alerts = Alert.order(id: :desc).limit(limit).includes(:tags, :artifacts)
14
+ alerts.map do |alert|
15
+ AlertSerializer.new(alert).as_json
16
+ end
39
17
  end
40
18
  end
41
19
  end
@@ -23,6 +23,10 @@ module Mihari
23
23
  raise NotImplementedError, "You must implement #{self.class}##{__method__}"
24
24
  end
25
25
 
26
+ def source
27
+ self.class.to_s.split("::").last
28
+ end
29
+
26
30
  # @return [Array<String>]
27
31
  def tags
28
32
  []
@@ -37,7 +41,7 @@ module Mihari
37
41
  end
38
42
 
39
43
  def run_emitter(emitter)
40
- emitter.run(title: title, description: description, artifacts: unique_artifacts, tags: tags)
44
+ emitter.run(title: title, description: description, artifacts: unique_artifacts, source: source, tags: tags)
41
45
  rescue StandardError => e
42
46
  puts "Emission by #{emitter.class} is failed: #{e}"
43
47
  end
@@ -48,32 +52,16 @@ module Mihari
48
52
 
49
53
  private
50
54
 
51
- def the_hive
52
- @the_hive ||= TheHive.new
53
- end
54
-
55
- def cache
56
- @cache ||= Cache.new
57
- end
58
-
59
55
  # @return [Array<Mihari::Artifact>]
60
56
  def normalized_artifacts
61
57
  @normalized_artifacts ||= artifacts.compact.uniq.sort.map do |artifact|
62
- artifact.is_a?(Artifact) ? artifact : Artifact.new(artifact)
58
+ artifact.is_a?(Artifact) ? artifact : Artifact.new(data: artifact)
63
59
  end.select(&:valid?)
64
60
  end
65
61
 
66
- def uncached_artifacts
67
- @uncached_artifacts ||= normalized_artifacts.reject do |artifact|
68
- cache.cached? artifact.data
69
- end
70
- end
71
-
72
62
  # @return [Array<Mihari::Artifact>]
73
63
  def unique_artifacts
74
- return uncached_artifacts unless the_hive.valid?
75
-
76
- @unique_artifacts ||= the_hive.artifact.find_non_existing_artifacts(uncached_artifacts)
64
+ @unique_artifacts ||= normalized_artifacts.select(&:unique?)
77
65
  end
78
66
 
79
67
  def set_unique_artifacts