logstash-input-okta_system_log 0.9.0
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 +4 -0
- data/CONTRIBUTORS +10 -0
- data/DEVELOPER.md +1 -0
- data/Gemfile +3 -0
- data/LICENSE +11 -0
- data/README.md +90 -0
- data/lib/logstash/inputs/okta_system_log.rb +1051 -0
- data/logstash-input-okta_system_log.gemspec +35 -0
- data/spec/inputs/okta_system_log_spec.rb +753 -0
- metadata +221 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Gem::Specification.new do |s|
|
|
2
|
+
s.name = 'logstash-input-okta_system_log'
|
|
3
|
+
s.version = '0.9.0'
|
|
4
|
+
s.licenses = ['Apache-2.0']
|
|
5
|
+
s.summary = 'This plugin fetches log events from Okta using the System Log API'
|
|
6
|
+
s.homepage = 'https://github.com/SecurityRiskAdvisors/logstash-input-okta_system_log'
|
|
7
|
+
s.authors = ['Security Risk Advisors']
|
|
8
|
+
s.email = 'security@securityriskadvisors.com'
|
|
9
|
+
s.require_paths = ['lib']
|
|
10
|
+
|
|
11
|
+
# Files
|
|
12
|
+
s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
|
|
13
|
+
# Tests
|
|
14
|
+
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
|
15
|
+
|
|
16
|
+
# Special flag to let us know this is actually a logstash plugin
|
|
17
|
+
s.metadata = { "logstash_plugin" => "true", "logstash_group" => "input" }
|
|
18
|
+
|
|
19
|
+
# Gem dependencies
|
|
20
|
+
s.add_runtime_dependency "logstash-core-plugin-api", ">= 1.60", "<= 2.99"
|
|
21
|
+
s.add_runtime_dependency 'logstash-codec-plain'
|
|
22
|
+
s.add_runtime_dependency 'stud', "~> 0.0.22"
|
|
23
|
+
#s.add_runtime_dependency 'logstash-mixin-http_client', ">= 6.0.0", "< 7.0.0"
|
|
24
|
+
s.add_runtime_dependency 'logstash-mixin-http_client', ">= 2.2.4", "< 7.0.0" # Maintains 2.4 compat
|
|
25
|
+
s.add_runtime_dependency 'rufus-scheduler', "~>3.0.9"
|
|
26
|
+
|
|
27
|
+
s.add_development_dependency 'logstash-codec-json'
|
|
28
|
+
s.add_development_dependency 'logstash-codec-line'
|
|
29
|
+
s.add_development_dependency 'logstash-devutils', '>= 0.0.16'
|
|
30
|
+
s.add_development_dependency 'flores'
|
|
31
|
+
s.add_development_dependency 'timecop'
|
|
32
|
+
s.add_development_dependency 'rake', "~> 12.1.0"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
end
|
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
require "logstash/devutils/rspec/spec_helper"
|
|
2
|
+
require 'logstash/inputs/okta_system_log'
|
|
3
|
+
require 'flores/random'
|
|
4
|
+
require "timecop"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "rspec/wait"
|
|
7
|
+
|
|
8
|
+
describe LogStash::Inputs::OktaSystemLog do
|
|
9
|
+
let(:queue) { Queue.new }
|
|
10
|
+
let(:default_schedule) {
|
|
11
|
+
{ "every" => "30s" }
|
|
12
|
+
}
|
|
13
|
+
let(:default_limit) { 1000 }
|
|
14
|
+
let(:default_auth_token_key) { "asdflkjasdflkjasdf932r098-asdf" }
|
|
15
|
+
let(:default_host) { "localhost" }
|
|
16
|
+
let(:metadata_target) { "_http_poller_metadata" }
|
|
17
|
+
let(:default_state_file_path) { "/dev/null" }
|
|
18
|
+
|
|
19
|
+
let(:default_opts) {
|
|
20
|
+
{
|
|
21
|
+
"schedule" => default_schedule,
|
|
22
|
+
"limit" => default_limit,
|
|
23
|
+
"hostname" => default_host,
|
|
24
|
+
"auth_token_key" => default_auth_token_key,
|
|
25
|
+
"metadata_target" => metadata_target,
|
|
26
|
+
"state_file_path" => default_state_file_path,
|
|
27
|
+
"codec" => "json"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let(:klass) { LogStash::Inputs::OktaSystemLog }
|
|
31
|
+
|
|
32
|
+
describe "config" do
|
|
33
|
+
shared_examples "configuration errors" do
|
|
34
|
+
it "raises an exception" do
|
|
35
|
+
expect {subject.register}.to raise_exception(LogStash::ConfigurationError)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
subject { klass.new(opts) }
|
|
40
|
+
|
|
41
|
+
before(:each) do
|
|
42
|
+
subject
|
|
43
|
+
allow(File).to receive(:directory?).with(opts["state_file_path"]) { false }
|
|
44
|
+
allow(File).to receive(:exist?).with(opts["state_file_path"]) { true }
|
|
45
|
+
allow(File).to receive(:stat).with(opts["state_file_path"]) { double("file_stat") }
|
|
46
|
+
# We don't really want to use the atomic write function
|
|
47
|
+
allow(subject).to receive(:detect_write_method).with(opts["state_file_path"]) { subject.method(:non_atomic_write) }
|
|
48
|
+
allow(File).to receive(:size).with(opts["state_file_path"]) { 0 }
|
|
49
|
+
allow(subject).to receive(:update_state_file) { nil }
|
|
50
|
+
|
|
51
|
+
# Might need these later
|
|
52
|
+
#allow(File).to receive(:read).with(opts["state_file_path"], 1) { "\n" }
|
|
53
|
+
#allow(LogStash::Environment).to receive(:windows?) { false }
|
|
54
|
+
#allow(File).to receive(:chardev?).with(opts["state_file_path"]) { false }
|
|
55
|
+
#allow(File).to receive(:blockdev?).with(opts["state_file_path"]) { false }
|
|
56
|
+
#allow(File).to receive(:socket?).with(opts["state_file_path"]) { false }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context "the hostname is not in the correct format" do
|
|
60
|
+
let(:opts) { default_opts.merge({"hostname" => "asdf__"}) }
|
|
61
|
+
include_examples("configuration errors")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
context "both hostname and custom_url are set" do
|
|
65
|
+
let(:opts) { default_opts.merge({"custom_url" => "http://localhost/foo/bar"}) }
|
|
66
|
+
include_examples("configuration errors")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context "custom_url is in an incorrect format" do
|
|
70
|
+
let(:opts) {
|
|
71
|
+
opts = default_opts.merge({"custom_url" => "http://___/foo/bar"}).clone
|
|
72
|
+
opts.delete("hostname")
|
|
73
|
+
opts
|
|
74
|
+
}
|
|
75
|
+
include_examples("configuration errors")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
context "The since parameter is not in the correct format" do
|
|
79
|
+
let(:opts) { default_opts.merge({"since" => "1234567890"}) }
|
|
80
|
+
include_examples("configuration errors")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
context "The limit parameter is too large" do
|
|
84
|
+
let(:opts) { default_opts.merge({"limit" => 10000}) }
|
|
85
|
+
include_examples("configuration errors")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
context "The limit is too small" do
|
|
89
|
+
let(:opts) { default_opts.merge({"limit" => -10000}) }
|
|
90
|
+
include_examples("configuration errors")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
context "the q parameter has too many items" do
|
|
94
|
+
let(:opts) { default_opts.merge({"q" => Array.new(size=11, obj="a")}) }
|
|
95
|
+
include_examples("configuration errors")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
context "the q parameter item has a space" do
|
|
99
|
+
let(:opts) { default_opts.merge({"q" => ["a b"]}) }
|
|
100
|
+
include_examples("configuration errors")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
context "the q parameter item is too long" do
|
|
104
|
+
let(:opts) { default_opts.merge({"q" => ["a" * 41]}) }
|
|
105
|
+
include_examples("configuration errors")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
context "the metadata target is not set" do
|
|
109
|
+
let(:opts) {
|
|
110
|
+
opts = default_opts.clone
|
|
111
|
+
opts.delete("metadata_target")
|
|
112
|
+
opts
|
|
113
|
+
}
|
|
114
|
+
it "sets the metadata function to apply_metadata" do
|
|
115
|
+
subject.register
|
|
116
|
+
expect(subject.instance_variable_get("@metadata_function")).to eql(subject.method(:apply_metadata))
|
|
117
|
+
expect(subject.instance_variable_get("@metadata_target")).to eql("@metadata")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
context "auth_token management" do
|
|
123
|
+
let(:auth_file_opts) {
|
|
124
|
+
auth_file_opts = default_opts.merge({"auth_token_file" => "/dev/null"}).clone
|
|
125
|
+
auth_file_opts.delete("auth_token_key")
|
|
126
|
+
auth_file_opts
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
context "custom_auth_header is defined with auth_token_key" do
|
|
130
|
+
let(:opts) {default_opts.merge({"custom_auth_header" => "Basic user:password"})}
|
|
131
|
+
include_examples("configuration errors")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
context "custom_auth_header is defined with auth_token_file" do
|
|
135
|
+
let(:opts) {auth_file_opts.merge({"custom_auth_header" => "Basic user:password"})}
|
|
136
|
+
include_examples("configuration errors")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
context "both auth_token key and file are provided" do
|
|
140
|
+
let(:opts) {default_opts.merge({"auth_token_file" => "/dev/null"})}
|
|
141
|
+
include_examples("configuration errors")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
context "neither auth_token key nor file are provided" do
|
|
145
|
+
let(:opts) {
|
|
146
|
+
opts = default_opts.clone
|
|
147
|
+
opts.delete("auth_token_key")
|
|
148
|
+
opts
|
|
149
|
+
}
|
|
150
|
+
include_examples("configuration errors")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
context "auth_token_file is too large" do
|
|
154
|
+
let(:opts) {auth_file_opts}
|
|
155
|
+
before {allow(File).to receive(:size).with(opts["auth_token_file"]) { 1 * 2**11 }}
|
|
156
|
+
include_examples("configuration errors")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
context "auth_token_file could not be read" do
|
|
160
|
+
let(:opts) {auth_file_opts}
|
|
161
|
+
before {
|
|
162
|
+
allow(File).to receive(:size).with(opts["auth_token_file"]) { 10 }
|
|
163
|
+
allow(File).to receive(:read).with(opts["auth_token_file"], 10) { raise IOError }
|
|
164
|
+
}
|
|
165
|
+
include_examples("configuration errors")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
context "auth_token returns an unauthorized error" do
|
|
169
|
+
let(:opts) { default_opts }
|
|
170
|
+
before do
|
|
171
|
+
subject.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
|
|
172
|
+
:body => "{}",
|
|
173
|
+
:code => klass::HTTP_UNAUTHORIZED_401
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
include_examples("configuration errors")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
describe "instances" do
|
|
182
|
+
subject { klass.new(default_opts) }
|
|
183
|
+
|
|
184
|
+
before do
|
|
185
|
+
subject.client.stub("https://#{default_opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
|
|
186
|
+
:body => "{}",
|
|
187
|
+
:code => klass::HTTP_OK_200
|
|
188
|
+
)
|
|
189
|
+
allow(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
190
|
+
allow(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
191
|
+
allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
192
|
+
# We don't really want to use the atomic write function
|
|
193
|
+
allow(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
194
|
+
allow(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
195
|
+
allow(subject).to receive(:update_state_file) { nil }
|
|
196
|
+
subject.register
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe "#run" do
|
|
200
|
+
it "should setup a scheduler" do
|
|
201
|
+
runner = Thread.new do
|
|
202
|
+
subject.run(double("queue"))
|
|
203
|
+
expect(subject.instance_variable_get("@scheduler")).to be_a_kind_of(Rufus::Scheduler)
|
|
204
|
+
end
|
|
205
|
+
runner.kill
|
|
206
|
+
runner.join
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe "#run_once" do
|
|
211
|
+
it "should issue an async request for each url" do
|
|
212
|
+
expect(subject).to receive(:request_async).with(queue).once
|
|
213
|
+
|
|
214
|
+
subject.send(:run_once, queue) # :run_once is a private method
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
describe "scheduler configuration" do
|
|
220
|
+
before do
|
|
221
|
+
instance.client.stub("https://#{default_opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
|
|
222
|
+
:body => "{}",
|
|
223
|
+
:code => klass::HTTP_OK_200
|
|
224
|
+
)
|
|
225
|
+
allow(File).to receive(:directory?).and_call_original
|
|
226
|
+
allow(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
227
|
+
allow(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
228
|
+
allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
229
|
+
# We don't really want to use the atomic write function
|
|
230
|
+
allow(instance).to receive(:detect_write_method).with(default_state_file_path) { instance.method(:non_atomic_write) }
|
|
231
|
+
allow(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
232
|
+
allow(instance).to receive(:update_state_file) { nil }
|
|
233
|
+
instance.register
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
context "given 'cron' expression" do
|
|
237
|
+
let(:opts) { default_opts.merge("schedule" => {"cron" => "* * * * * UTC"}) }
|
|
238
|
+
let(:instance) { klass.new(opts) }
|
|
239
|
+
it "should run at the schedule" do
|
|
240
|
+
Timecop.travel(Time.new(2000,1,1,0,0,0,'+00:00'))
|
|
241
|
+
Timecop.scale(60)
|
|
242
|
+
queue = Queue.new
|
|
243
|
+
runner = Thread.new do
|
|
244
|
+
instance.run(queue)
|
|
245
|
+
end
|
|
246
|
+
sleep 3
|
|
247
|
+
instance.stop
|
|
248
|
+
runner.kill
|
|
249
|
+
runner.join
|
|
250
|
+
expect(queue.size).to eq(2)
|
|
251
|
+
Timecop.return
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
context "given 'at' expression" do
|
|
256
|
+
let(:opts) { default_opts.merge("schedule" => {"at" => "2000-01-01 00:05:00 +0000"}) }
|
|
257
|
+
let(:instance) { klass.new(opts) }
|
|
258
|
+
it "should run at the schedule" do
|
|
259
|
+
Timecop.travel(Time.new(2000,1,1,0,0,0,'+00:00'))
|
|
260
|
+
Timecop.scale(60 * 5)
|
|
261
|
+
queue = Queue.new
|
|
262
|
+
runner = Thread.new do
|
|
263
|
+
instance.run(queue)
|
|
264
|
+
end
|
|
265
|
+
sleep 2
|
|
266
|
+
instance.stop
|
|
267
|
+
runner.kill
|
|
268
|
+
runner.join
|
|
269
|
+
expect(queue.size).to eq(1)
|
|
270
|
+
Timecop.return
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
context "given 'every' expression" do
|
|
275
|
+
let(:opts) { default_opts.merge("schedule" => {"every" => "2s"}) }
|
|
276
|
+
let(:instance) { klass.new(opts) }
|
|
277
|
+
it "should run at the schedule" do
|
|
278
|
+
queue = Queue.new
|
|
279
|
+
runner = Thread.new do
|
|
280
|
+
instance.run(queue)
|
|
281
|
+
end
|
|
282
|
+
#T 0123456
|
|
283
|
+
#events x x x x
|
|
284
|
+
#expects 3 events at T=5
|
|
285
|
+
sleep 5
|
|
286
|
+
instance.stop
|
|
287
|
+
runner.kill
|
|
288
|
+
runner.join
|
|
289
|
+
expect(queue.size).to eq(3)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
context "given 'in' expression" do
|
|
294
|
+
let(:opts) { default_opts.merge("schedule" => {"in" => "2s"}) }
|
|
295
|
+
let(:instance) { klass.new(opts) }
|
|
296
|
+
it "should run at the schedule" do
|
|
297
|
+
queue = Queue.new
|
|
298
|
+
runner = Thread.new do
|
|
299
|
+
instance.run(queue)
|
|
300
|
+
end
|
|
301
|
+
sleep 3
|
|
302
|
+
instance.stop
|
|
303
|
+
runner.kill
|
|
304
|
+
runner.join
|
|
305
|
+
expect(queue.size).to eq(1)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
describe "events" do
|
|
311
|
+
shared_examples("matching metadata") {
|
|
312
|
+
let(:metadata) { event.get(metadata_target) }
|
|
313
|
+
let(:options) { defined?(settings) ? settings : opts }
|
|
314
|
+
# The URL gets modified b/c of the limit that is placed on the API
|
|
315
|
+
#let(:metadata_url) { "https://#{options["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{options["limit"]}" }
|
|
316
|
+
let(:metadata_url) {
|
|
317
|
+
if (custom_settings)
|
|
318
|
+
options["custom_url"]+"?limit=#{options["limit"]}"
|
|
319
|
+
else
|
|
320
|
+
"https://#{options["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{options["limit"]}"
|
|
321
|
+
end
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
it "should have the correct request url" do
|
|
325
|
+
expect(metadata["url"].to_s).to eql(metadata_url)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "should have the correct code" do
|
|
329
|
+
expect(metadata["code"]).to eql(code)
|
|
330
|
+
end
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
shared_examples "unprocessable_requests" do
|
|
334
|
+
let(:poller) { klass.new(settings) }
|
|
335
|
+
subject(:event) {
|
|
336
|
+
poller.send(:run_once, queue)
|
|
337
|
+
queue.pop(true)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
before do
|
|
341
|
+
unless (custom_settings)
|
|
342
|
+
poller.client.stub("https://#{settings["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
|
|
343
|
+
:body => "{}",
|
|
344
|
+
:code => klass::HTTP_OK_200
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
allow(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
348
|
+
allow(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
349
|
+
allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
350
|
+
# We don't really want to use the atomic write function
|
|
351
|
+
allow(poller).to receive(:detect_write_method).with(default_state_file_path) { poller.method(:non_atomic_write) }
|
|
352
|
+
allow(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
353
|
+
allow(poller).to receive(:update_state_file) { nil }
|
|
354
|
+
poller.register
|
|
355
|
+
allow(poller).to receive(:handle_failure).and_call_original
|
|
356
|
+
allow(poller).to receive(:handle_success)
|
|
357
|
+
event # materialize the subject
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "should enqueue a message" do
|
|
361
|
+
expect(event).to be_a(LogStash::Event)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "should enqueue a message with 'http_request_error' set" do
|
|
365
|
+
expect(event.get("http_request_error")).to be_a(Hash)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
it "should tag the event with '_http_request_error'" do
|
|
369
|
+
expect(event.get("tags")).to include('_http_request_error')
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it "should invoke handle failure exactly once" do
|
|
373
|
+
expect(poller).to have_received(:handle_failure).once
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
it "should not invoke handle success at all" do
|
|
377
|
+
expect(poller).not_to have_received(:handle_success)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
include_examples("matching metadata")
|
|
381
|
+
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
context "with a non responsive server" do
|
|
385
|
+
context "due to an invalid hostname" do # Fail with handlers
|
|
386
|
+
let(:custom_settings) { false }
|
|
387
|
+
let(:hostname) { "thouetnhoeu89ueoueohtueohtneuohn" }
|
|
388
|
+
let(:code) { nil } # no response expected
|
|
389
|
+
|
|
390
|
+
let(:settings) { default_opts.merge("hostname" => hostname) }
|
|
391
|
+
|
|
392
|
+
include_examples("unprocessable_requests")
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
context "due to a non-existent host" do # Fail with handlers
|
|
396
|
+
let(:custom_settings) { true }
|
|
397
|
+
let(:custom_url) { "http://thouetnhoeu89ueoueohtueohtneuohn/path/api" }
|
|
398
|
+
let(:code) { nil } # no response expected
|
|
399
|
+
|
|
400
|
+
let(:settings) {
|
|
401
|
+
|
|
402
|
+
settings = default_opts.merge("custom_url" => custom_url).clone
|
|
403
|
+
settings.delete("hostname")
|
|
404
|
+
settings
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
include_examples("unprocessable_requests")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
end
|
|
411
|
+
context "due to a bogus port number" do # fail with return?
|
|
412
|
+
let(:invalid_port) { Flores::Random.integer(65536..1000000) }
|
|
413
|
+
let(:custom_settings) { true }
|
|
414
|
+
let(:custom_url) { "http://127.0.0.1:#{invalid_port}" }
|
|
415
|
+
let(:settings) {
|
|
416
|
+
settings = default_opts.merge("custom_url" => custom_url.to_s).clone
|
|
417
|
+
settings.delete("hostname")
|
|
418
|
+
settings
|
|
419
|
+
}
|
|
420
|
+
let(:code) { nil } # No response expected
|
|
421
|
+
|
|
422
|
+
include_examples("unprocessable_requests")
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
describe "a valid request and decoded response" do
|
|
427
|
+
let(:payload) {{"a" => 2, "hello" => ["a", "b", "c"]}}
|
|
428
|
+
let(:response_body) { LogStash::Json.dump(payload) }
|
|
429
|
+
let(:code) { klass::HTTP_OK_200 }
|
|
430
|
+
let(:hostname) { default_host }
|
|
431
|
+
let(:custom_settings) { false }
|
|
432
|
+
|
|
433
|
+
let(:opts) { default_opts }
|
|
434
|
+
let(:instance) {
|
|
435
|
+
klass.new(opts)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
subject(:event) {
|
|
439
|
+
queue.pop(true)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
before do
|
|
443
|
+
instance.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
|
|
444
|
+
:body => "{}",
|
|
445
|
+
:code => klass::HTTP_OK_200
|
|
446
|
+
)
|
|
447
|
+
allow(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
448
|
+
allow(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
449
|
+
allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
450
|
+
# We don't really want to use the atomic write function
|
|
451
|
+
allow(instance).to receive(:detect_write_method).with(default_state_file_path) { instance.method(:non_atomic_write) }
|
|
452
|
+
allow(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
453
|
+
allow(instance).to receive(:update_state_file) { nil }
|
|
454
|
+
|
|
455
|
+
instance.register
|
|
456
|
+
allow(instance).to receive(:decorate)
|
|
457
|
+
instance.client.stub(%r{#{opts["hostname"]}.*},
|
|
458
|
+
:body => response_body,
|
|
459
|
+
:code => code
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
instance.send(:run_once, queue)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
it "should have a matching message" do
|
|
466
|
+
expect(event.to_hash).to include(payload)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
it "should decorate the event" do
|
|
470
|
+
expect(instance).to have_received(:decorate).once
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
include_examples("matching metadata")
|
|
474
|
+
|
|
475
|
+
context "with an empty body" do
|
|
476
|
+
let(:response_body) { "" }
|
|
477
|
+
it "should return an empty event" do
|
|
478
|
+
instance.send(:run_once, queue)
|
|
479
|
+
expect(event.get("[_http_poller_metadata][response_headers][content-length]")).to eql("0")
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
context "with metadata omitted" do
|
|
484
|
+
let(:opts) {
|
|
485
|
+
opts = default_opts.clone
|
|
486
|
+
opts.delete("metadata_target")
|
|
487
|
+
opts
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
it "should not have any metadata on the event" do
|
|
491
|
+
instance.send(:run_once, queue)
|
|
492
|
+
expect(event.get(metadata_target)).to be_nil
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
context "with a specified target" do
|
|
497
|
+
let(:target) { "mytarget" }
|
|
498
|
+
let(:opts) { default_opts.merge("target" => target) }
|
|
499
|
+
|
|
500
|
+
it "should store the event info in the target" do
|
|
501
|
+
# When events go through the pipeline they are java-ified
|
|
502
|
+
# this normalizes the payload to java types
|
|
503
|
+
payload_normalized = LogStash::Json.load(LogStash::Json.dump(payload))
|
|
504
|
+
expect(event.get(target)).to include(payload_normalized)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
context "with non-200 HTTP response codes" do
|
|
509
|
+
let(:code) { |example| example.metadata[:http_code] }
|
|
510
|
+
let(:response_body) { "{}" }
|
|
511
|
+
|
|
512
|
+
it "responds to a 500 code", :http_code => 500 do
|
|
513
|
+
instance.send(:run_once, queue)
|
|
514
|
+
expect(event.to_hash).to include("http_response_error")
|
|
515
|
+
expect(event.to_hash["http_response_error"]).to include({"http_code" => code})
|
|
516
|
+
expect(event.get("tags")).to include('_http_response_error')
|
|
517
|
+
end
|
|
518
|
+
it "responds to a 401/Unauthorized code", :http_code => 401 do
|
|
519
|
+
instance.send(:run_once, queue)
|
|
520
|
+
expect(event.to_hash).to include("okta_response_error")
|
|
521
|
+
expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
|
|
522
|
+
expect(event.get("tags")).to include('_okta_response_error')
|
|
523
|
+
end
|
|
524
|
+
it "responds to a 400 code", :http_code => 400 do
|
|
525
|
+
instance.send(:run_once, queue)
|
|
526
|
+
expect(event.to_hash).to include("okta_response_error")
|
|
527
|
+
expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
|
|
528
|
+
expect(event.get("tags")).to include('_okta_response_error')
|
|
529
|
+
end
|
|
530
|
+
context "specific okta errors" do
|
|
531
|
+
let(:payload) { {:okta_error => "E0000031" } }
|
|
532
|
+
let(:response_body) { LogStash::Json.dump(payload) }
|
|
533
|
+
|
|
534
|
+
describe "filter string error" do
|
|
535
|
+
let(:payload) { {:okta_error => "E0000031" } }
|
|
536
|
+
let(:response_body) { LogStash::Json.dump(payload) }
|
|
537
|
+
it "generates a filter string error event", :http_code => 400 do
|
|
538
|
+
expect(event.to_hash).to include("okta_response_error")
|
|
539
|
+
expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
|
|
540
|
+
expect(event.to_hash["okta_response_error"]).to include({"okta_plugin_status" => "Filter string was not valid."})
|
|
541
|
+
expect(event.get("tags")).to include('_okta_response_error')
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
describe "start_date error" do
|
|
546
|
+
let(:payload) { {:okta_error => "E0000030" } }
|
|
547
|
+
let(:response_body) { LogStash::Json.dump(payload) }
|
|
548
|
+
it "generates a start_date error event", :http_code => 400 do
|
|
549
|
+
expect(event.to_hash).to include("okta_response_error")
|
|
550
|
+
expect(event.to_hash["okta_response_error"]).to include({"http_code" => code})
|
|
551
|
+
expect(event.to_hash["okta_response_error"]).to include({"okta_plugin_status" => "since was not valid."})
|
|
552
|
+
expect(event.get("tags")).to include('_okta_response_error')
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
describe "stopping" do
|
|
561
|
+
let(:config) { default_opts }
|
|
562
|
+
before do
|
|
563
|
+
allow(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
564
|
+
allow(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
565
|
+
allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
566
|
+
# We don't really want to use the atomic write function
|
|
567
|
+
allow(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
568
|
+
allow(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
569
|
+
allow(subject).to receive(:update_state_file) { nil }
|
|
570
|
+
end
|
|
571
|
+
it_behaves_like "an interruptible input plugin"
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
describe "state file" do
|
|
575
|
+
context "when being setup" do
|
|
576
|
+
|
|
577
|
+
let(:opts) {
|
|
578
|
+
opts = default_opts.merge({"state_file_path" => default_state_file_path}).clone
|
|
579
|
+
opts
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
subject { klass.new(opts) }
|
|
583
|
+
|
|
584
|
+
let(:state_file_url) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{opts["limit"]}&after=asdfasdf" }
|
|
585
|
+
let(:test_url) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?limit=#{opts["limit"]}" }
|
|
586
|
+
let(:state_file_url_changed) { "http://example.com/?limit=1000" }
|
|
587
|
+
|
|
588
|
+
before(:each) do
|
|
589
|
+
subject.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
|
|
590
|
+
:body => "{}",
|
|
591
|
+
:code => klass::HTTP_OK_200
|
|
592
|
+
)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
it "sets up the state file correctly" do
|
|
597
|
+
expect(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
598
|
+
expect(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
599
|
+
expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
600
|
+
# We don't really want to use the atomic write function
|
|
601
|
+
expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
602
|
+
expect(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
603
|
+
subject.register
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
it "raises an error on file read" do
|
|
607
|
+
expect(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
608
|
+
expect(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
609
|
+
expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
610
|
+
# We don't really want to use the atomic write function
|
|
611
|
+
expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
612
|
+
expect(File).to receive(:size).with(default_state_file_path) { 10 }
|
|
613
|
+
expect(File).to receive(:read).with(default_state_file_path, 10) { raise IOError }
|
|
614
|
+
expect {subject.register}.to raise_exception(LogStash::ConfigurationError)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
it "creates a url based on the state file" do
|
|
618
|
+
expect(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
619
|
+
expect(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
620
|
+
expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
621
|
+
# We don't really want to use the atomic write function
|
|
622
|
+
expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
623
|
+
expect(File).to receive(:size).with(default_state_file_path) { "#{state_file_url}\n".length }
|
|
624
|
+
expect(File).to receive(:read).with(default_state_file_path, "#{state_file_url}\n".length) { "#{state_file_url}\n" }
|
|
625
|
+
subject.register
|
|
626
|
+
expect(subject.instance_variable_get("@url")).to eql(state_file_url)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
it "uses the URL from options when state file is empty" do
|
|
630
|
+
expect(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
631
|
+
expect(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
632
|
+
expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
633
|
+
# We don't really want to use the atomic write function
|
|
634
|
+
expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
635
|
+
expect(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
636
|
+
subject.register
|
|
637
|
+
expect(subject.instance_variable_get("@url").to_s).to eql(test_url)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
it "raises an error when the config url is not part of the saved state" do
|
|
641
|
+
expect(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
642
|
+
expect(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
643
|
+
expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
644
|
+
# We don't really want to use the atomic write function
|
|
645
|
+
expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
646
|
+
expect(File).to receive(:size).with(default_state_file_path) { "#{state_file_url_changed}\n".length }
|
|
647
|
+
expect(File).to receive(:read).with(default_state_file_path, "#{state_file_url_changed}\n".length) { "#{state_file_url_changed}\n" }
|
|
648
|
+
expect {subject.register}.to raise_exception(LogStash::ConfigurationError)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
it "sets the the failure mode to error" do
|
|
652
|
+
expect(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
653
|
+
expect(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
654
|
+
expect(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
655
|
+
# We don't really want to use the atomic write function
|
|
656
|
+
expect(subject).to receive(:detect_write_method).with(default_state_file_path) { subject.method(:non_atomic_write) }
|
|
657
|
+
expect(File).to receive(:size).with(default_state_file_path) { 0 }
|
|
658
|
+
subject.register
|
|
659
|
+
expect(subject.instance_variable_get("@state_file_failure_function")).to eql(subject.method(:error_state_file))
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
context "when running" do
|
|
664
|
+
let(:opts) {
|
|
665
|
+
opts = default_opts.merge({"state_file_path" => default_state_file_path}).clone
|
|
666
|
+
opts
|
|
667
|
+
}
|
|
668
|
+
let(:instance) { klass.new(opts) }
|
|
669
|
+
|
|
670
|
+
let(:payload) { '[{"eventId":"tevIMARaEyiSzm3sm1gvfn8cA1479235809000"}]}]' }
|
|
671
|
+
let(:response_body) { LogStash::Json.dump(payload) }
|
|
672
|
+
|
|
673
|
+
let(:url_initial) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?after=1" }
|
|
674
|
+
let(:url_final) { "https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH}?after=2" }
|
|
675
|
+
let(:headers) { {"link" => ["<#{url_initial}>; rel=\"self\"", "<#{url_final}>; rel=\"next\""]} }
|
|
676
|
+
let(:code) { klass::HTTP_OK_200 }
|
|
677
|
+
let(:file_path) { opts['state_file_dir'] + opts["state_file_prefix"] }
|
|
678
|
+
let(:file_obj) { double("file") }
|
|
679
|
+
let(:fd) { double("fd") }
|
|
680
|
+
let(:time_anchor) { 2 }
|
|
681
|
+
|
|
682
|
+
before(:each) do |example|
|
|
683
|
+
allow(File).to receive(:directory?).with(default_state_file_path) { false }
|
|
684
|
+
allow(File).to receive(:exist?).with(default_state_file_path) { true }
|
|
685
|
+
allow(File).to receive(:stat).with(default_state_file_path) { double("file_stat") }
|
|
686
|
+
# We don't really want to use the atomic write function
|
|
687
|
+
allow(instance).to receive(:detect_write_method).with(default_state_file_path) { instance.method(:non_atomic_write) }
|
|
688
|
+
allow(File).to receive(:size).with(default_state_file_path) { "#{url_initial}\n".length }
|
|
689
|
+
allow(File).to receive(:read).with(default_state_file_path, "#{url_initial}\n".length) { "#{url_initial}\n" }
|
|
690
|
+
|
|
691
|
+
instance.client.stub("https://#{opts["hostname"]+klass::OKTA_EVENT_LOG_PATH+klass::AUTH_TEST_URL}",
|
|
692
|
+
:body => "{}",
|
|
693
|
+
:code => code
|
|
694
|
+
)
|
|
695
|
+
instance.register
|
|
696
|
+
instance.client.stub( url_initial,
|
|
697
|
+
:headers => headers,
|
|
698
|
+
:body => response_body,
|
|
699
|
+
:code => code )
|
|
700
|
+
|
|
701
|
+
allow(instance).to receive(:handle_failure) { instance.instance_variable_set(:@continue,false) }
|
|
702
|
+
allow(instance).to receive(:get_time_int) { time_anchor }
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
it "updates the state file after data is fetched" do
|
|
706
|
+
expect(IO).to receive(:sysopen).with(default_state_file_path, "w+") { fd }
|
|
707
|
+
expect(IO).to receive(:open).with(fd).and_yield(file_obj)
|
|
708
|
+
expect(file_obj).to receive(:write).with("#{url_final}\n") { url_final.length + 1 }
|
|
709
|
+
instance.client.stub( url_final,
|
|
710
|
+
:headers => {:link => "<#{url_final}>; rel=\"self\""},
|
|
711
|
+
:body => "{}",
|
|
712
|
+
:code => code )
|
|
713
|
+
instance.send(:run_once, queue)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
it "updates the state file after a failure" do
|
|
717
|
+
expect(IO).to receive(:sysopen).with(default_state_file_path, "w+") { fd }
|
|
718
|
+
expect(IO).to receive(:open).with(fd).and_yield(file_obj)
|
|
719
|
+
expect(file_obj).to receive(:write).with("#{url_final}\n") { url_final.length + 1 }
|
|
720
|
+
instance.send(:run_once, queue)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
context "when stop is called" do
|
|
724
|
+
it "saves the state in the file" do
|
|
725
|
+
# We are still testing the same condition
|
|
726
|
+
expect(IO).to receive(:sysopen).with(default_state_file_path, "w+") { fd }
|
|
727
|
+
expect(IO).to receive(:open).with(fd).and_yield(file_obj)
|
|
728
|
+
expect(file_obj).to receive(:write).with("#{url_final}\n") { url_final.length + 1 }
|
|
729
|
+
|
|
730
|
+
# Force a sleep to make the thread hang in the failure condition.
|
|
731
|
+
allow(instance).to receive(:handle_failure) {
|
|
732
|
+
instance.instance_variable_set(:@continue,false)
|
|
733
|
+
sleep(30)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
plugin_thread = Thread.new(instance,queue) { |subject, queue|
|
|
737
|
+
instance.send(:run, queue)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
# Sleep for a bit to make sure things are started.
|
|
741
|
+
sleep 0.5
|
|
742
|
+
expect(plugin_thread).to be_alive
|
|
743
|
+
|
|
744
|
+
instance.do_stop
|
|
745
|
+
|
|
746
|
+
# As they say in the logstash thread, why 3?
|
|
747
|
+
# Because 2 is too short, and 4 is too long.
|
|
748
|
+
wait(3).for { plugin_thread }.to_not be_alive
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|