logstash-filter-cipher 2.0.2 → 2.0.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -0
- data/README.md +3 -0
- data/lib/logstash/filters/cipher.rb +128 -19
- data/logstash-filter-cipher.gemspec +1 -1
- data/spec/filters/cipher_spec.rb +214 -4
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: beeedcce9482610f01afe1d5a6abb3dadf1be9d8
|
4
|
+
data.tar.gz: e8ff3189814e38d63e9729893642978404386191
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 90237b428e896c39f895e85b68d781225b1ff2d7edcbdb3103cbec327d2de918c8dd3843b799b98024bfc36762583f673bb3c4d637f4b42a787a565e44be6fa9
|
7
|
+
data.tar.gz: 7df726409a16267b2f71a9027b7a0a25fc4b962d7c8816534592f886f9d2842ccd51f39fd03fde880ff97f668c6cbb8b404c4fef27059b683a9ef71c7fe836cc
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,6 @@
|
|
1
|
+
## 2.0.3
|
2
|
+
- fixes base64 encoding issue, adds support for random IVs
|
3
|
+
|
1
4
|
## 2.0.0
|
2
5
|
- Plugins were updated to follow the new shutdown semantic, this mainly allows Logstash to instruct input plugins to terminate gracefully,
|
3
6
|
instead of using Thread.raise on the plugins' threads. Ref: https://github.com/elastic/logstash/pull/3895
|
data/README.md
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# Logstash Plugin
|
2
2
|
|
3
|
+
[](http://build-eu-00.elastic.co/view/LS%20Plugins/view/LS%20Filters/job/logstash-plugin-filter-cipher-unit/)
|
5
|
+
|
3
6
|
This is a plugin for [Logstash](https://github.com/elastic/logstash).
|
4
7
|
|
5
8
|
It is fully free and fully open source. The license is Apache 2.0, meaning you are pretty much free to use it however you want in whatever way.
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
require "logstash/filters/base"
|
3
3
|
require "logstash/namespace"
|
4
|
+
require "openssl"
|
4
5
|
|
5
6
|
|
6
7
|
# This filter parses a source and apply a cipher or decipher before
|
@@ -31,23 +32,31 @@ class LogStash::Filters::Cipher < LogStash::Filters::Base
|
|
31
32
|
config :base64, :validate => :boolean, :default => true
|
32
33
|
|
33
34
|
# The key to use
|
35
|
+
#
|
36
|
+
# NOTE: If you encounter an error message at runtime containing the following:
|
37
|
+
#
|
38
|
+
# "java.security.InvalidKeyException: Illegal key size: possibly you need to install
|
39
|
+
# Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files for your JRE"
|
40
|
+
#
|
41
|
+
# Please read the following: https://github.com/jruby/jruby/wiki/UnlimitedStrengthCrypto
|
42
|
+
#
|
34
43
|
config :key, :validate => :string
|
35
44
|
|
36
45
|
# The key size to pad
|
37
46
|
#
|
38
|
-
# It depends of the cipher
|
47
|
+
# It depends of the cipher algorithm. If your key doesn't need
|
39
48
|
# padding, don't set this parameter
|
40
49
|
#
|
41
|
-
# Example, for AES-
|
50
|
+
# Example, for AES-128, we must have 16 char long key. AES-256 = 32 chars
|
42
51
|
# [source,ruby]
|
43
|
-
# filter { cipher { key_size =>
|
52
|
+
# filter { cipher { key_size => 16 }
|
44
53
|
#
|
45
|
-
config :key_size, :validate => :number, :default =>
|
54
|
+
config :key_size, :validate => :number, :default => 16
|
46
55
|
|
47
56
|
# The character used to pad the key
|
48
57
|
config :key_pad, :default => "\0"
|
49
58
|
|
50
|
-
# The cipher
|
59
|
+
# The cipher algorithm
|
51
60
|
#
|
52
61
|
# A list of supported algorithms can be obtained by
|
53
62
|
# [source,ruby]
|
@@ -59,12 +68,12 @@ class LogStash::Filters::Cipher < LogStash::Filters::Base
|
|
59
68
|
# Valid values are encrypt or decrypt
|
60
69
|
config :mode, :validate => :string, :required => true
|
61
70
|
|
62
|
-
#
|
71
|
+
# Cipher padding to use. Enables or disables padding.
|
63
72
|
#
|
64
|
-
# By default encryption operations are padded using standard block padding
|
65
|
-
# and the padding is checked and removed when decrypting. If the pad
|
66
|
-
# parameter is zero then no padding is performed, the total amount of data
|
67
|
-
# encrypted or decrypted must then be a multiple of the block size or an
|
73
|
+
# By default encryption operations are padded using standard block padding
|
74
|
+
# and the padding is checked and removed when decrypting. If the pad
|
75
|
+
# parameter is zero then no padding is performed, the total amount of data
|
76
|
+
# encrypted or decrypted must then be a multiple of the block size or an
|
68
77
|
# error will occur.
|
69
78
|
#
|
70
79
|
# See EVP_CIPHER_CTX_set_padding for further information.
|
@@ -73,16 +82,57 @@ class LogStash::Filters::Cipher < LogStash::Filters::Base
|
|
73
82
|
# If you want to change it, set this parameter. If you want to disable
|
74
83
|
# it, Set this parameter to 0
|
75
84
|
# [source,ruby]
|
76
|
-
# filter { cipher {
|
85
|
+
# filter { cipher { cipher_padding => 0 }}
|
77
86
|
config :cipher_padding, :validate => :string
|
78
87
|
|
79
|
-
# The initialization vector to use
|
88
|
+
# The initialization vector to use (statically hard-coded). For
|
89
|
+
# a random IV see the iv_random_length property
|
90
|
+
#
|
91
|
+
# NOTE: If iv_random_length is set, it takes precedence over any value set for "iv"
|
80
92
|
#
|
81
93
|
# The cipher modes CBC, CFB, OFB and CTR all need an "initialization
|
82
94
|
# vector", or short, IV. ECB mode is the only mode that does not require
|
83
95
|
# an IV, but there is almost no legitimate use case for this mode
|
84
96
|
# because of the fact that it does not sufficiently hide plaintext patterns.
|
85
|
-
|
97
|
+
#
|
98
|
+
# For AES algorithms set this to a 16 byte string.
|
99
|
+
# [source,ruby]
|
100
|
+
# filter { cipher { iv => "1234567890123456" }}
|
101
|
+
#
|
102
|
+
# Deprecated: Please use `iv_random_length` instead
|
103
|
+
config :iv, :validate => :string, :deprecated => "Please use 'iv_random_length'"
|
104
|
+
|
105
|
+
# Force an random IV to be used per encryption invocation and specify
|
106
|
+
# the length of the random IV that will be generated via:
|
107
|
+
#
|
108
|
+
# OpenSSL::Random.random_bytes(int_length)
|
109
|
+
#
|
110
|
+
# If iv_random_length is set, it takes precedence over any value set for "iv"
|
111
|
+
#
|
112
|
+
# Enabling this will force the plugin to generate a unique
|
113
|
+
# random IV for each encryption call. This random IV will be prepended to the
|
114
|
+
# encrypted result bytes and then base64 encoded. On decryption "iv_random_length" must
|
115
|
+
# also be set to utilize this feature. Random IV's are better than statically
|
116
|
+
# hardcoded IVs
|
117
|
+
#
|
118
|
+
# For AES algorithms you can set this to a 16
|
119
|
+
# [source,ruby]
|
120
|
+
# filter { cipher { iv_random_length => 16 }}
|
121
|
+
config :iv_random_length, :validate => :number
|
122
|
+
|
123
|
+
# If this is set the internal Cipher instance will be
|
124
|
+
# re-used up to @max_cipher_reuse times before being
|
125
|
+
# reset() and re-created from scratch. This is an option
|
126
|
+
# for efficiency where lots of data is being encrypted
|
127
|
+
# and decrypted using this filter. This lets the filter
|
128
|
+
# avoid creating new Cipher instances over and over
|
129
|
+
# for each encrypt/decrypt operation.
|
130
|
+
#
|
131
|
+
# This is optional, the default is no re-use of the Cipher
|
132
|
+
# instance and max_cipher_reuse = 1 by default
|
133
|
+
# [source,ruby]
|
134
|
+
# filter { cipher { max_cipher_reuse => 1000 }}
|
135
|
+
config :max_cipher_reuse, :validate => :number, :default => 1
|
86
136
|
|
87
137
|
def register
|
88
138
|
require 'base64' if @base64
|
@@ -96,30 +146,82 @@ class LogStash::Filters::Cipher < LogStash::Filters::Base
|
|
96
146
|
|
97
147
|
#If decrypt or encrypt fails, we keep it it intact.
|
98
148
|
begin
|
149
|
+
|
150
|
+
if (event[@source].nil? || event[@source].empty?)
|
151
|
+
@logger.debug("Event to filter, event 'source' field: " + @source + " was null(nil) or blank, doing nothing")
|
152
|
+
return
|
153
|
+
end
|
154
|
+
|
99
155
|
#@logger.debug("Event to filter", :event => event)
|
100
156
|
data = event[@source]
|
101
157
|
if @mode == "decrypt"
|
102
|
-
data = Base64.
|
158
|
+
data = Base64.strict_decode64(data) if @base64 == true
|
159
|
+
|
160
|
+
if !@iv_random_length.nil?
|
161
|
+
@random_iv = data.byteslice(0,@iv_random_length)
|
162
|
+
data = data.byteslice(@iv_random_length..data.length)
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
|
167
|
+
if !@iv_random_length.nil? and @mode == "encrypt"
|
168
|
+
@random_iv = OpenSSL::Random.random_bytes(@iv_random_length)
|
103
169
|
end
|
170
|
+
|
171
|
+
# if iv_random_length is specified, generate a new one
|
172
|
+
# and force the cipher's IV = to the random value
|
173
|
+
if !@iv_random_length.nil?
|
174
|
+
@cipher.iv = @random_iv
|
175
|
+
end
|
176
|
+
|
104
177
|
result = @cipher.update(data) + @cipher.final
|
178
|
+
|
105
179
|
if @mode == "encrypt"
|
106
|
-
|
180
|
+
|
181
|
+
# if we have a random_iv, prepend that to the crypted result
|
182
|
+
if !@random_iv.nil?
|
183
|
+
result = @random_iv + result
|
184
|
+
end
|
185
|
+
|
186
|
+
result = Base64.strict_encode64(result).encode("utf-8") if @base64 == true
|
107
187
|
end
|
188
|
+
|
108
189
|
rescue => e
|
109
190
|
@logger.warn("Exception catch on cipher filter", :event => event, :error => e)
|
191
|
+
|
192
|
+
# force a re-initialize on error to be safe
|
193
|
+
init_cipher
|
194
|
+
|
110
195
|
else
|
196
|
+
@total_cipher_uses += 1
|
197
|
+
|
198
|
+
result = result.force_encoding("utf-8") if @mode == "decrypt"
|
199
|
+
|
111
200
|
event[@target]= result
|
201
|
+
|
112
202
|
#Is it necessary to add 'if !result.nil?' ? exception have been already catched.
|
113
203
|
#In doubt, I keep it.
|
114
204
|
filter_matched(event) if !result.nil?
|
115
|
-
|
116
|
-
|
205
|
+
|
206
|
+
if !@max_cipher_reuse.nil? and @total_cipher_uses >= @max_cipher_reuse
|
207
|
+
@logger.debug("max_cipher_reuse["+@max_cipher_reuse.to_s+"] reached, total_cipher_uses = "+@total_cipher_uses.to_s)
|
208
|
+
init_cipher
|
209
|
+
end
|
210
|
+
|
117
211
|
end
|
118
212
|
end # def filter
|
119
213
|
|
120
214
|
def init_cipher
|
121
215
|
|
216
|
+
if !@cipher.nil?
|
217
|
+
@cipher.reset
|
218
|
+
@cipher = nil
|
219
|
+
end
|
220
|
+
|
122
221
|
@cipher = OpenSSL::Cipher.new(@algorithm)
|
222
|
+
|
223
|
+
@total_cipher_uses = 0
|
224
|
+
|
123
225
|
if @mode == "encrypt"
|
124
226
|
@cipher.encrypt
|
125
227
|
elsif @mode == "decrypt"
|
@@ -136,11 +238,18 @@ class LogStash::Filters::Cipher < LogStash::Filters::Base
|
|
136
238
|
|
137
239
|
@cipher.key = @key
|
138
240
|
|
139
|
-
|
241
|
+
if !@iv.nil? and !@iv.empty? and @iv_random_length.nil?
|
242
|
+
@cipher.iv = @iv if @iv
|
243
|
+
|
244
|
+
elsif !@iv_random_length.nil?
|
245
|
+
@logger.debug("iv_random_length is configured, ignoring any statically defined value for 'iv'", :iv_random_length => @iv_random_length)
|
246
|
+
else
|
247
|
+
raise "cipher plugin: either 'iv' or 'iv_random_length' must be configured, but not both; aborting"
|
248
|
+
end
|
140
249
|
|
141
250
|
@cipher.padding = @cipher_padding if @cipher_padding
|
142
251
|
|
143
|
-
@logger.debug("Cipher initialisation done", :mode => @mode, :key => @key, :iv => @iv, :cipher_padding => @cipher_padding)
|
252
|
+
@logger.debug("Cipher initialisation done", :mode => @mode, :key => @key, :iv => @iv, :iv_random => @iv_random, :cipher_padding => @cipher_padding)
|
144
253
|
end # def init_cipher
|
145
254
|
|
146
255
|
|
@@ -1,7 +1,7 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
|
3
3
|
s.name = 'logstash-filter-cipher'
|
4
|
-
s.version = '2.0.
|
4
|
+
s.version = '2.0.3'
|
5
5
|
s.licenses = ['Apache License (2.0)']
|
6
6
|
s.summary = "This filter parses a source and apply a cipher or decipher before storing it in the target"
|
7
7
|
s.description = "This gem is a logstash plugin required to be installed on top of the Logstash core pipeline using $LS_HOME/bin/plugin install gemname. This gem is not a stand-alone program"
|
data/spec/filters/cipher_spec.rb
CHANGED
@@ -1,5 +1,215 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require "logstash/devutils/rspec/spec_helper"
|
4
|
+
require 'logstash/filters/cipher'
|
5
|
+
|
6
|
+
describe LogStash::Filters::Cipher do
|
7
|
+
|
8
|
+
let(:cleartext) do
|
9
|
+
'شسيبشن٤٤ت٥ت داھدساققبمر фывапролдзщшгнекутцйячсмить asdfghjklqpoiuztreyxcvbnm,.-öäü+ä123ß´yö.,;LÖÜ*O 來少精清皆人址法田手扌打表氵開日大木裝 1234567890#$%^&*()!@#;:\'?.>,<testAESEn+=_-~`}]'
|
10
|
+
end
|
11
|
+
|
12
|
+
let(:event) do
|
13
|
+
"{\"message\":\"#{cleartext}\"}"
|
14
|
+
end
|
15
|
+
|
16
|
+
let(:pipeline) { LogStash::Pipeline.new(config) }
|
17
|
+
|
18
|
+
let(:events) do
|
19
|
+
arr = event.is_a?(Array) ? event : [event]
|
20
|
+
arr.map do |evt|
|
21
|
+
LogStash::Event.new(evt.is_a?(String) ? LogStash::Json.load(evt) : evt)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
let(:results) do
|
26
|
+
pipeline.instance_eval { @filters.each(&:register) }
|
27
|
+
results = []
|
28
|
+
events.each do |evt|
|
29
|
+
# filter call the block on all filtered events, included new events added by the filter
|
30
|
+
pipeline.filter(evt) do |filtered_event|
|
31
|
+
results.push(filtered_event)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
pipeline.flush_filters(:final => true) { |flushed_event| results << flushed_event }
|
35
|
+
|
36
|
+
results.select { |e| !e.cancelled? }
|
37
|
+
end
|
38
|
+
|
39
|
+
describe 'single event, encrypt/decrypt aes-128-cbc, 16b RANDOM IV, 16b key, b64 encode' do
|
40
|
+
|
41
|
+
let(:config) do
|
42
|
+
<<-CONFIG
|
43
|
+
filter {
|
44
|
+
|
45
|
+
cipher {
|
46
|
+
algorithm => "aes-128-cbc"
|
47
|
+
cipher_padding => 1
|
48
|
+
iv_random_length => 16
|
49
|
+
key => "1234567890123456"
|
50
|
+
key_size => 16
|
51
|
+
mode => "encrypt"
|
52
|
+
source => "message"
|
53
|
+
target => "message_crypted"
|
54
|
+
base64 => true
|
55
|
+
max_cipher_reuse => 1
|
56
|
+
}
|
57
|
+
|
58
|
+
cipher {
|
59
|
+
algorithm => "aes-128-cbc"
|
60
|
+
cipher_padding => 1
|
61
|
+
iv_random_length => 16
|
62
|
+
key => "1234567890123456"
|
63
|
+
key_size => 16
|
64
|
+
mode => "decrypt"
|
65
|
+
source => "message_crypted"
|
66
|
+
target => "message_decrypted"
|
67
|
+
base64 => true
|
68
|
+
max_cipher_reuse => 1
|
69
|
+
}
|
70
|
+
}
|
71
|
+
CONFIG
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'validate initial cleartext message' do
|
75
|
+
result = results.first
|
76
|
+
expect(result["message"]).to eq(cleartext)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'validate decrypted message' do
|
80
|
+
result = results.first
|
81
|
+
expect(result["message_decrypted"]).to eq(result["message"])
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'validate encrypted message is not equal to message' do
|
85
|
+
result = results.first
|
86
|
+
expect(result["message"]).not_to eq(result["message_crypted"])
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
describe 'single event, encrypt/decrypt aes-128-cbc, 16b STATIC IV, 16b key, b64 encode' do
|
93
|
+
|
94
|
+
let(:config) do
|
95
|
+
<<-CONFIG
|
96
|
+
filter {
|
97
|
+
|
98
|
+
cipher {
|
99
|
+
algorithm => "aes-128-cbc"
|
100
|
+
cipher_padding => 1
|
101
|
+
iv => "1234567890123456"
|
102
|
+
key => "1234567890123456"
|
103
|
+
key_size => 16
|
104
|
+
mode => "encrypt"
|
105
|
+
source => "message"
|
106
|
+
target => "message_crypted"
|
107
|
+
base64 => true
|
108
|
+
max_cipher_reuse => 1
|
109
|
+
}
|
110
|
+
|
111
|
+
cipher {
|
112
|
+
algorithm => "aes-128-cbc"
|
113
|
+
cipher_padding => 1
|
114
|
+
iv => "1234567890123456"
|
115
|
+
key => "1234567890123456"
|
116
|
+
key_size => 16
|
117
|
+
mode => "decrypt"
|
118
|
+
source => "message_crypted"
|
119
|
+
target => "message_decrypted"
|
120
|
+
base64 => true
|
121
|
+
max_cipher_reuse => 1
|
122
|
+
}
|
123
|
+
}
|
124
|
+
CONFIG
|
125
|
+
end
|
126
|
+
|
127
|
+
it 'validate initial cleartext message' do
|
128
|
+
result = results.first
|
129
|
+
expect(result["message"]).to eq(cleartext)
|
130
|
+
end
|
131
|
+
|
132
|
+
it 'validate decrypted message' do
|
133
|
+
result = results.first
|
134
|
+
expect(result["message_decrypted"]).to eq(result["message"])
|
135
|
+
end
|
136
|
+
|
137
|
+
it 'validate encrypted message is not equal to message' do
|
138
|
+
result = results.first
|
139
|
+
expect(result["message"]).not_to eq(result["message_crypted"])
|
140
|
+
end
|
141
|
+
|
142
|
+
end
|
143
|
+
|
144
|
+
|
145
|
+
describe '1000 events, 11 re-use, encrypt/decrypt aes-128-cbc, 16b RANDOM IV, 16b key, b64 encode' do
|
146
|
+
|
147
|
+
total_events = 1000
|
148
|
+
|
149
|
+
let(:event) do
|
150
|
+
events = []
|
151
|
+
(1..total_events).each do |i|
|
152
|
+
events.push("{\"message\":\"#{cleartext}\"}")
|
153
|
+
end
|
154
|
+
return events
|
155
|
+
end
|
156
|
+
|
157
|
+
let(:config) do
|
158
|
+
<<-CONFIG
|
159
|
+
filter {
|
160
|
+
|
161
|
+
cipher {
|
162
|
+
algorithm => "aes-128-cbc"
|
163
|
+
cipher_padding => 1
|
164
|
+
iv_random_length => 16
|
165
|
+
key => "1234567890123456"
|
166
|
+
key_size => 16
|
167
|
+
mode => "encrypt"
|
168
|
+
source => "message"
|
169
|
+
target => "message_crypted"
|
170
|
+
base64 => true
|
171
|
+
max_cipher_reuse => 11
|
172
|
+
}
|
173
|
+
|
174
|
+
cipher {
|
175
|
+
algorithm => "aes-128-cbc"
|
176
|
+
cipher_padding => 1
|
177
|
+
iv_random_length => 16
|
178
|
+
key => "1234567890123456"
|
179
|
+
key_size => 16
|
180
|
+
mode => "decrypt"
|
181
|
+
source => "message_crypted"
|
182
|
+
target => "message_decrypted"
|
183
|
+
base64 => true
|
184
|
+
max_cipher_reuse => 11
|
185
|
+
}
|
186
|
+
}
|
187
|
+
CONFIG
|
188
|
+
end
|
189
|
+
|
190
|
+
it 'validate total events' do
|
191
|
+
expect(results.length).to eq(total_events)
|
192
|
+
end
|
193
|
+
|
194
|
+
it 'validate initial cleartext message' do
|
195
|
+
results.each do |result|
|
196
|
+
expect(result["message"]).to eq(cleartext)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
it 'validate decrypted message' do
|
201
|
+
results.each do |result|
|
202
|
+
expect(result["message_decrypted"]).to eq(result["message"])
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
it 'validate encrypted message is not equal to message' do
|
207
|
+
results.each do |result|
|
208
|
+
expect(result["message"]).not_to eq(result["message_crypted"])
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
end
|
213
|
+
|
214
|
+
end
|
3
215
|
|
4
|
-
describe LogStash::Filters::Cipher do
|
5
|
-
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: logstash-filter-cipher
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.
|
4
|
+
version: 2.0.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Elastic
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2016-01-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|