logstash-filter-threats_classifier 1.0.4
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 +7 -0
- data/CHANGELOG.md +2 -0
- data/CONTRIBUTORS +11 -0
- data/Gemfile +2 -0
- data/LICENSE +11 -0
- data/README.md +64 -0
- data/lib/logstash/filters/center-client.rb +213 -0
- data/lib/logstash/filters/classification-request.rb +17 -0
- data/lib/logstash/filters/classifier-cache.rb +51 -0
- data/lib/logstash/filters/classifier.rb +335 -0
- data/lib/logstash/filters/cognito-client.rb +48 -0
- data/lib/logstash/filters/elastic-db.rb +128 -0
- data/lib/logstash/filters/field-handler.rb +127 -0
- data/lib/logstash/filters/local-classifier.rb +94 -0
- data/lib/logstash/filters/plugin-logic.rb +166 -0
- data/lib/logstash/filters/response.rb +36 -0
- data/lib/logstash/filters/threats_classifier.rb +230 -0
- data/lib/logstash/filters/utils.rb +46 -0
- data/logstash-filter-threats_classifier.gemspec +38 -0
- data/spec/filters/bulk-processor_spec.rb +92 -0
- data/spec/filters/classifier-cache_spec.rb +44 -0
- data/spec/filters/classifier_spec.rb +78 -0
- data/spec/filters/cognito-client_spec.rb +20 -0
- data/spec/filters/field-handler_spec.rb +101 -0
- data/spec/filters/local-classifier_spec.rb +46 -0
- data/spec/filters/plugin-logic_spec.rb +127 -0
- data/spec/filters/threats-classifier_spec.rb +103 -0
- data/spec/filters/utils_spec.rb +74 -0
- data/spec/spec_helper.rb +2 -0
- metadata +256 -0
@@ -0,0 +1,36 @@
|
|
1
|
+
module LogStash
|
2
|
+
module Filters
|
3
|
+
module Empow
|
4
|
+
class AbstractResponse
|
5
|
+
attr_reader :response, :is_successful, :is_final
|
6
|
+
|
7
|
+
def initialize(response, is_successful, is_final)
|
8
|
+
@response = response
|
9
|
+
@is_successful = is_successful
|
10
|
+
@is_final = is_final
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class FailureResponse < AbstractResponse
|
15
|
+
def initialize(response)
|
16
|
+
super(response, false, true)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class UnauthorizedReponse < FailureResponse
|
21
|
+
end
|
22
|
+
|
23
|
+
class SuccessfulResponse < AbstractResponse
|
24
|
+
def initialize(response)
|
25
|
+
super(response, true, true)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class InProgressResponse < AbstractResponse
|
30
|
+
def initialize(response)
|
31
|
+
super(response, true, false)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,230 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require "logstash/filters/base"
|
3
|
+
require "elasticsearch"
|
4
|
+
|
5
|
+
require_relative "elastic-db"
|
6
|
+
require_relative "local-classifier"
|
7
|
+
require_relative "classifier"
|
8
|
+
require_relative "center-client"
|
9
|
+
require_relative "plugin-logic"
|
10
|
+
require_relative "utils"
|
11
|
+
|
12
|
+
#
|
13
|
+
class LogStash::Filters::Threats_Classifier < LogStash::Filters::Base
|
14
|
+
|
15
|
+
config_name "threats_classifier"
|
16
|
+
|
17
|
+
# The username (typically your email address), to access the classification center
|
18
|
+
config :username, :validate => :string, :required => true
|
19
|
+
|
20
|
+
# The password to access the classification center
|
21
|
+
config :password, :validate => :string, :required => true
|
22
|
+
|
23
|
+
# Set this value only if using the complete empow stack; leave unchanged if using the empow Elastic open source plugin or module
|
24
|
+
config :authentication_hash, :validate => :string, :default => '131n94ktfg7lj8hlpnnbkuiql1'
|
25
|
+
|
26
|
+
# The number of responses cached locally
|
27
|
+
config :cache_size, :validate => :number, :default => 10000
|
28
|
+
|
29
|
+
# Max number of requests pending response from the classification center
|
30
|
+
config :max_pending_requests, :validate => :number, :default => 10000
|
31
|
+
|
32
|
+
# Timeout for response from classification center (seconds)
|
33
|
+
config :pending_request_timeout, :validate => :number, :default => 60
|
34
|
+
|
35
|
+
# Maximum number of concurrent threads (workers) classifying logs using the classification center
|
36
|
+
config :max_classification_center_workers, :validate => :number, :default => 5
|
37
|
+
|
38
|
+
# Classification center bulk request size (requests)
|
39
|
+
config :bulk_request_size, :validate => :number, :default => 50
|
40
|
+
|
41
|
+
# Time (seconds) to wait for batch to fill on classifciation center, before querying for the response
|
42
|
+
config :bulk_request_interval, :validate => :number, :default => 2
|
43
|
+
|
44
|
+
# Max number of classification center request retries
|
45
|
+
config :max_query_retries, :validate => :number, :default => 5
|
46
|
+
|
47
|
+
# Time (seconds) to wait between queries to the classification center for the final response to a request; the classification center will return an 'in-progress' response if queried before the final response is ready
|
48
|
+
config :time_between_queries, :validate => :number, :default => 10
|
49
|
+
|
50
|
+
# The name of the product type field in the log
|
51
|
+
# Example: If the log used log_type for the product type, configure the plugin like this:
|
52
|
+
# [source,ruby]
|
53
|
+
# filter {
|
54
|
+
# empowclassifier {
|
55
|
+
# username => "happy"
|
56
|
+
# password => "festivus"
|
57
|
+
# product_type_field => "log_type"
|
58
|
+
# }
|
59
|
+
# }
|
60
|
+
config :product_type_field, :validate => :string, :default => "product_type"
|
61
|
+
|
62
|
+
# The name of the product name field in the log
|
63
|
+
# Example: If the log used product for the product name, configure the plugin like this:
|
64
|
+
# [source,ruby]
|
65
|
+
# filter {
|
66
|
+
# empowclassifier {
|
67
|
+
# username => "happy"
|
68
|
+
# password => "festivus"
|
69
|
+
# product_name_field => "product"
|
70
|
+
# }
|
71
|
+
# }
|
72
|
+
config :product_name_field, :validate => :string, :default => "product_name"
|
73
|
+
|
74
|
+
# The name of the field containing the terms sent to the classification center
|
75
|
+
config :threat_field, :validate => :string, :default => "threat"
|
76
|
+
|
77
|
+
# Indicates whether the source field is internal to the user’s network (for example, an internal host/mail/user/app)
|
78
|
+
config :src_internal_field, :validate => :string, :default => "is_src_internal"
|
79
|
+
|
80
|
+
# Indicates whether the dest field is internal to the user’s network (for example, an internal host/mail/user/app)
|
81
|
+
config :dst_internal_field, :validate => :string, :default => "is_dst_internal"
|
82
|
+
|
83
|
+
# changes the api root for customers of the commercial empow stack
|
84
|
+
config :base_url, :validate => :string, :default => ""
|
85
|
+
|
86
|
+
config :async_local_cache, :validate => :boolean, :default => true
|
87
|
+
|
88
|
+
# elastic config params
|
89
|
+
########################
|
90
|
+
|
91
|
+
config :elastic_hosts, :validate => :array
|
92
|
+
|
93
|
+
# The index or alias to write to
|
94
|
+
config :elastic_index, :validate => :string, :default => "empow-intent-db"
|
95
|
+
|
96
|
+
config :elastic_user, :validate => :string
|
97
|
+
config :elastic_password, :validate => :password
|
98
|
+
|
99
|
+
# failure tags
|
100
|
+
###############
|
101
|
+
config :tag_on_product_type_failure, :validate => :array, :default => ['_empow_no_product_type']
|
102
|
+
config :tag_on_signature_failure, :validate => :array, :default => ['_empow_no_signature']
|
103
|
+
config :tag_on_timeout, :validate => :array, :default => ['_empow_classifier_timeout']
|
104
|
+
config :tag_on_error, :validate => :array, :default => ['_empow_classifier_error']
|
105
|
+
|
106
|
+
CLASSIFICATION_URL = 'https://intent.cloud.empow.co'
|
107
|
+
CACHE_TTL = (24*60*60)
|
108
|
+
|
109
|
+
public
|
110
|
+
def register
|
111
|
+
@logger.info("registering empow classifcation plugin")
|
112
|
+
|
113
|
+
validate_params()
|
114
|
+
|
115
|
+
local_db = create_local_database
|
116
|
+
|
117
|
+
local_classifier = LogStash::Filters::Empow::LocalClassifier.new(@cache_size, CACHE_TTL, @async_local_cache, local_db)
|
118
|
+
|
119
|
+
base_url = get_effective_url()
|
120
|
+
online_classifier = LogStash::Filters::Empow::ClassificationCenterClient.new(@username, @password, @authentication_hash, base_url)
|
121
|
+
|
122
|
+
classifer = LogStash::Filters::Empow::Classifier.new(online_classifier, local_classifier, @max_classification_center_workers, @bulk_request_size, @bulk_request_interval, @max_query_retries, @time_between_queries)
|
123
|
+
|
124
|
+
field_handler = LogStash::Filters::Empow::FieldHandler.new(@product_type_field, @product_name_field, @threat_field, @src_internal_field, @dst_internal_field)
|
125
|
+
|
126
|
+
@plugin_core ||= LogStash::Filters::Empow::PluginLogic.new(classifer, field_handler, @pending_request_timeout, @max_pending_requests, @tag_on_timeout, @tag_on_error)
|
127
|
+
|
128
|
+
@logger.info("empow classifcation plugin registered")
|
129
|
+
end # def register
|
130
|
+
|
131
|
+
private
|
132
|
+
def get_effective_url
|
133
|
+
if (@base_url.nil? or @base_url.strip == 0)
|
134
|
+
return CLASSIFICATION_URL
|
135
|
+
end
|
136
|
+
|
137
|
+
return CLASSIFICATION_URL
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
def validate_params
|
142
|
+
raise ArgumentError, 'threat field cannot be empty' if LogStash::Filters::Empow::Utils.is_blank_string(@threat_field)
|
143
|
+
|
144
|
+
raise ArgumentError, 'bulk_request_size must be an positive number between 1 and 1000' if (@bulk_request_size < 1 or @bulk_request_size > 1000)
|
145
|
+
|
146
|
+
raise ArgumentError, 'bulk_request_interval must be an greater or equal to 1' if (@bulk_request_interval < 1)
|
147
|
+
end
|
148
|
+
|
149
|
+
def close
|
150
|
+
@logger.info("closing the empow classifcation plugin")
|
151
|
+
|
152
|
+
@plugin_core.close
|
153
|
+
|
154
|
+
@logger.info("empow classifcation plugin closed")
|
155
|
+
end
|
156
|
+
|
157
|
+
def periodic_flush
|
158
|
+
true
|
159
|
+
end
|
160
|
+
|
161
|
+
public def flush(options = {})
|
162
|
+
@logger.debug("entered flush")
|
163
|
+
|
164
|
+
events_to_flush = []
|
165
|
+
|
166
|
+
begin
|
167
|
+
parked_events = @plugin_core.flush(options)
|
168
|
+
|
169
|
+
parked_events.each do |event|
|
170
|
+
event.uncancel
|
171
|
+
|
172
|
+
events_to_flush << event
|
173
|
+
end
|
174
|
+
|
175
|
+
rescue StandardError => e
|
176
|
+
@logger.error("encountered an exception while processing flush", :error => e)
|
177
|
+
end
|
178
|
+
|
179
|
+
@logger.debug("flush ended", :flushed_event_count => events_to_flush.length)
|
180
|
+
|
181
|
+
return events_to_flush
|
182
|
+
end
|
183
|
+
|
184
|
+
public def filter(event)
|
185
|
+
res = event
|
186
|
+
|
187
|
+
begin
|
188
|
+
res = @plugin_core.classify(event)
|
189
|
+
|
190
|
+
if res.nil?
|
191
|
+
return
|
192
|
+
end
|
193
|
+
|
194
|
+
# event was classified and returned, not some overflow event
|
195
|
+
if res.equal? event
|
196
|
+
filter_matched(event)
|
197
|
+
|
198
|
+
return
|
199
|
+
end
|
200
|
+
|
201
|
+
# got here with a parked event
|
202
|
+
filter_matched(res)
|
203
|
+
|
204
|
+
@logger.debug("filter matched for overflow event", :event => res)
|
205
|
+
|
206
|
+
yield res
|
207
|
+
|
208
|
+
rescue StandardError => e
|
209
|
+
@logger.error("encountered an exception while classifying", :error => e, :event => event, :backtrace => e.backtrace)
|
210
|
+
|
211
|
+
@tag_on_error.each{|tag| event.tag(tag)}
|
212
|
+
end
|
213
|
+
end # def filter
|
214
|
+
|
215
|
+
private def create_local_database
|
216
|
+
# if no elastic host has been configured, no local db should be used
|
217
|
+
if @elastic_hosts.nil?
|
218
|
+
@logger.info("no local persisted cache is configured")
|
219
|
+
return nil
|
220
|
+
end
|
221
|
+
|
222
|
+
begin
|
223
|
+
return LogStash::Filters::Empow::PersistentKeyValueDB.new(:elastic_hosts, :elastic_user, :elastic_password, :elastic_index)
|
224
|
+
rescue StandardError => e
|
225
|
+
@logger.error("caught an exception while trying to configured persisted cache", e)
|
226
|
+
end
|
227
|
+
|
228
|
+
return nil
|
229
|
+
end
|
230
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module LogStash; module Filters; module Empow
|
2
|
+
|
3
|
+
class Utils
|
4
|
+
TRUTHY_VALUES = [true, 1, '1']
|
5
|
+
FALSEY_VALUES = [false, 0, '0']
|
6
|
+
|
7
|
+
def self.is_blank_string(txt)
|
8
|
+
return (txt.nil? or txt.strip.length == 0)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.convert_to_boolean(val)
|
12
|
+
return nil if val.nil?
|
13
|
+
|
14
|
+
return true if TRUTHY_VALUES.include?(val)
|
15
|
+
|
16
|
+
return false if FALSEY_VALUES.include?(val)
|
17
|
+
|
18
|
+
return true if (val.is_a?(String) and val.downcase.strip == 'true')
|
19
|
+
|
20
|
+
return false if (val.is_a?(String) and val.downcase.strip == 'false')
|
21
|
+
|
22
|
+
return nil
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.add_error(event, msg)
|
26
|
+
tag_empow_messages(event, msg, 'empow_errors')
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.add_warn(event, msg)
|
30
|
+
tag_empow_messages(event, msg, 'empow_warnings')
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
def self.tag_empow_messages(event, msg, block)
|
35
|
+
messages = event.get(block)
|
36
|
+
|
37
|
+
# using arrayinstead of set, as set raises a logstash exception:
|
38
|
+
# No enum constant org.logstash.bivalues.BiValues.ORG_JRUBY_RUBYOBJECTVAR0
|
39
|
+
messages ||= Array.new
|
40
|
+
messages << msg
|
41
|
+
|
42
|
+
event.set(block, messages.uniq)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
end; end; end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'logstash-filter-threats_classifier'
|
3
|
+
s.version = '1.0.4'
|
4
|
+
s.licenses = ['Apache-2.0']
|
5
|
+
s.summary = 'Returns classification information for attacks from the empow classification center, based on information in log strings'
|
6
|
+
#s.description = 'Write a longer description or delete this line.'
|
7
|
+
s.homepage = 'http://www.empow.co'
|
8
|
+
s.authors = ['empow', 'Assaf Abulafia', 'Rami Cohen']
|
9
|
+
s.email = ''
|
10
|
+
s.require_paths = ['lib']
|
11
|
+
|
12
|
+
# Files
|
13
|
+
s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
|
14
|
+
# Tests
|
15
|
+
|
16
|
+
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
17
|
+
|
18
|
+
# Special flag to let us know this is actually a logstash plugin
|
19
|
+
s.metadata = { "logstash_plugin" => "true", "logstash_group" => "filter" }
|
20
|
+
|
21
|
+
# Gem dependencies
|
22
|
+
s.add_runtime_dependency "logstash-core-plugin-api", ">= 1.60", "<= 2.99"
|
23
|
+
s.add_runtime_dependency 'rest-client', '~> 1.8', '>= 1.8.0'
|
24
|
+
s.add_runtime_dependency 'lru_redux', '~> 1.1', '>= 1.1.0'
|
25
|
+
s.add_runtime_dependency 'json', '~> 1.8', '>= 1.8'
|
26
|
+
#s.add_runtime_dependency 'rufus-scheduler'
|
27
|
+
s.add_runtime_dependency 'hashie'
|
28
|
+
#s.add_runtime_dependency "murmurhash3"
|
29
|
+
|
30
|
+
s.add_development_dependency 'aws-sdk', '~> 3'
|
31
|
+
|
32
|
+
s.add_development_dependency 'logstash-devutils'
|
33
|
+
# s.add_runtime_dependency 'jwt', '~> 2.1', '>= 2.1.0'
|
34
|
+
s.add_development_dependency "timecop", "~> 0.7"
|
35
|
+
s.add_development_dependency "webmock", "~> 1.22", ">= 1.21.0"
|
36
|
+
|
37
|
+
s.add_development_dependency 'elasticsearch'
|
38
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
require "logstash/filters/classifier"
|
3
|
+
require "logstash/filters/local-classifier"
|
4
|
+
require "logstash/filters/classification-request"
|
5
|
+
require "logstash/filters/center-client"
|
6
|
+
require "logstash/filters/response"
|
7
|
+
require 'timecop'
|
8
|
+
|
9
|
+
describe LogStash::Filters::Empow::Classification::BulkProcessor do
|
10
|
+
#empow_user, empow_password, cache_size, ttl, async_local_db, elastic_hosts, elastic_index, elastic_username, elastic_password
|
11
|
+
let(:time_between_attempts) { 1 }
|
12
|
+
let(:batch_size) { 10 }
|
13
|
+
let(:max_retries) { 5 }
|
14
|
+
|
15
|
+
describe "test with mocked classifiers" do
|
16
|
+
it "single failed log" do
|
17
|
+
|
18
|
+
Timecop.freeze(Time.now)
|
19
|
+
|
20
|
+
req1 = "request1"
|
21
|
+
val1 = {}
|
22
|
+
val1[:retries] = 1
|
23
|
+
val1[:task] = nil
|
24
|
+
val1[:request] = req1
|
25
|
+
val1[:last_executed] = Time.at(310953600)
|
26
|
+
|
27
|
+
requests = Hash.new
|
28
|
+
requests[req1] = val1
|
29
|
+
|
30
|
+
local_classifier = instance_double(LogStash::Filters::Empow::LocalClassifier)
|
31
|
+
allow(local_classifier).to receive(:classify).and_return(nil)
|
32
|
+
allow(local_classifier).to receive(:close)
|
33
|
+
|
34
|
+
center_result = {}
|
35
|
+
center_result[req1] = LogStash::Filters::Empow::FailureReponse.new("failure1")
|
36
|
+
|
37
|
+
online_classifer = instance_double(LogStash::Filters::Empow::ClassificationCenterClient)
|
38
|
+
allow(online_classifer).to receive(:classify).and_return(center_result)
|
39
|
+
|
40
|
+
bulk_processor = described_class.new(max_retries, batch_size, time_between_attempts, requests, online_classifer, local_classifier)
|
41
|
+
|
42
|
+
expect(online_classifer).to receive(:classify)
|
43
|
+
expect(local_classifier).to receive(:add_to_cache)
|
44
|
+
|
45
|
+
bulk_processor.execute
|
46
|
+
|
47
|
+
#expect(local_classifier).to receive(:add_to_cache)
|
48
|
+
|
49
|
+
# expect(res).to be_nil
|
50
|
+
#save_to_cache_and_db
|
51
|
+
|
52
|
+
expect(requests[req1]).to be_nil
|
53
|
+
|
54
|
+
#Timecop.freeze(Time.now + time_between_attempts)
|
55
|
+
#Timecop.freeze(Time.now + 1 + time_between_attempts)
|
56
|
+
end
|
57
|
+
|
58
|
+
it "single successful log" do
|
59
|
+
|
60
|
+
Timecop.freeze(Time.now)
|
61
|
+
|
62
|
+
req1 = "request1"
|
63
|
+
val1 = {}
|
64
|
+
val1[:retries] = 1
|
65
|
+
val1[:task] = nil
|
66
|
+
val1[:request] = req1
|
67
|
+
val1[:last_executed] = Time.at(310953600)
|
68
|
+
|
69
|
+
requests = Hash.new
|
70
|
+
requests[req1] = val1
|
71
|
+
|
72
|
+
local_classifier = instance_double(LogStash::Filters::Empow::LocalClassifier)
|
73
|
+
allow(local_classifier).to receive(:classify).and_return(nil)
|
74
|
+
allow(local_classifier).to receive(:close)
|
75
|
+
|
76
|
+
center_result = {}
|
77
|
+
center_result[req1] = LogStash::Filters::Empow::SuccessfulReponse.new("result1")
|
78
|
+
|
79
|
+
online_classifer = instance_double(LogStash::Filters::Empow::ClassificationCenterClient)
|
80
|
+
allow(online_classifer).to receive(:classify).and_return(center_result)
|
81
|
+
|
82
|
+
bulk_processor = described_class.new(max_retries, batch_size, time_between_attempts, requests, online_classifer, local_classifier)
|
83
|
+
|
84
|
+
expect(online_classifer).to receive(:classify)
|
85
|
+
expect(local_classifier).to receive(:save_to_cache_and_db)
|
86
|
+
|
87
|
+
bulk_processor.execute
|
88
|
+
|
89
|
+
expect(requests[req1]).to be_nil
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
require 'timecop'
|
3
|
+
require "logstash/filters/classifier-cache"
|
4
|
+
|
5
|
+
describe LogStash::Filters::Empow::ClassifierCache do
|
6
|
+
|
7
|
+
describe "initialize signaure test" do
|
8
|
+
it "test expiration by cache default ttl" do
|
9
|
+
cache = described_class.new(5, 60)
|
10
|
+
|
11
|
+
expect(cache.classify("k")).to be_nil
|
12
|
+
|
13
|
+
Timecop.freeze(Time.now)
|
14
|
+
|
15
|
+
cache.put("k", "v", Time.now + 24*60*60)
|
16
|
+
|
17
|
+
Timecop.freeze(Time.now + 59)
|
18
|
+
|
19
|
+
expect(cache.classify("k")).to eq("v")
|
20
|
+
|
21
|
+
Timecop.freeze(Time.now + 61)
|
22
|
+
|
23
|
+
expect(cache.classify("k")).to be_nil
|
24
|
+
end
|
25
|
+
|
26
|
+
it "test expiration by entry ttl" do
|
27
|
+
cache = described_class.new(5, 60)
|
28
|
+
|
29
|
+
expect(cache.classify("k")).to be_nil
|
30
|
+
|
31
|
+
Timecop.freeze(Time.now)
|
32
|
+
|
33
|
+
cache.put("k", "v", Time.now + 30)
|
34
|
+
|
35
|
+
Timecop.freeze(Time.now + 29)
|
36
|
+
|
37
|
+
expect(cache.classify("k")).to eq("v")
|
38
|
+
|
39
|
+
Timecop.freeze(Time.now + 31)
|
40
|
+
|
41
|
+
expect(cache.classify("k")).to be_nil
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|