sqreen 0.1.0.pre → 0.7.01461158029
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CODE_OF_CONDUCT.md +22 -0
- data/README.md +77 -0
- data/Rakefile +40 -0
- data/lib/sqreen.rb +67 -0
- data/lib/sqreen/binding_accessor.rb +184 -0
- data/lib/sqreen/ca.crt +72 -0
- data/lib/sqreen/callback_tree.rb +78 -0
- data/lib/sqreen/callbacks.rb +120 -0
- data/lib/sqreen/capped_queue.rb +23 -0
- data/lib/sqreen/condition_evaluator.rb +169 -0
- data/lib/sqreen/conditionable.rb +50 -0
- data/lib/sqreen/configuration.rb +151 -0
- data/lib/sqreen/context.rb +22 -0
- data/lib/sqreen/deliveries/batch.rb +80 -0
- data/lib/sqreen/deliveries/simple.rb +36 -0
- data/lib/sqreen/detect.rb +14 -0
- data/lib/sqreen/detect/shell_injection.rb +61 -0
- data/lib/sqreen/detect/sql_injection.rb +115 -0
- data/lib/sqreen/event.rb +16 -0
- data/lib/sqreen/events/attack.rb +60 -0
- data/lib/sqreen/events/remote_exception.rb +53 -0
- data/lib/sqreen/exception.rb +31 -0
- data/lib/sqreen/frameworks.rb +40 -0
- data/lib/sqreen/frameworks/generic.rb +243 -0
- data/lib/sqreen/frameworks/rails.rb +155 -0
- data/lib/sqreen/frameworks/rails3.rb +36 -0
- data/lib/sqreen/frameworks/sinatra.rb +34 -0
- data/lib/sqreen/frameworks/sqreen_test.rb +26 -0
- data/lib/sqreen/instrumentation.rb +504 -0
- data/lib/sqreen/log.rb +116 -0
- data/lib/sqreen/metrics.rb +6 -0
- data/lib/sqreen/metrics/average.rb +39 -0
- data/lib/sqreen/metrics/base.rb +41 -0
- data/lib/sqreen/metrics/collect.rb +22 -0
- data/lib/sqreen/metrics/sum.rb +20 -0
- data/lib/sqreen/metrics_store.rb +94 -0
- data/lib/sqreen/parsers/sql.rb +98 -0
- data/lib/sqreen/parsers/sql_tokenizer.rb +266 -0
- data/lib/sqreen/parsers/unix.rb +110 -0
- data/lib/sqreen/payload_creator.rb +132 -0
- data/lib/sqreen/performance_notifications.rb +86 -0
- data/lib/sqreen/performance_notifications/log.rb +36 -0
- data/lib/sqreen/performance_notifications/metrics.rb +36 -0
- data/lib/sqreen/performance_notifications/newrelic.rb +36 -0
- data/lib/sqreen/remote_command.rb +82 -0
- data/lib/sqreen/rule_attributes.rb +25 -0
- data/lib/sqreen/rule_callback.rb +97 -0
- data/lib/sqreen/rules.rb +116 -0
- data/lib/sqreen/rules_callbacks.rb +29 -0
- data/lib/sqreen/rules_callbacks/binding_accessor_metrics.rb +79 -0
- data/lib/sqreen/rules_callbacks/count_http_codes.rb +18 -0
- data/lib/sqreen/rules_callbacks/crawler_user_agent_matches.rb +24 -0
- data/lib/sqreen/rules_callbacks/crawler_user_agent_matches_metrics.rb +25 -0
- data/lib/sqreen/rules_callbacks/execjs.rb +136 -0
- data/lib/sqreen/rules_callbacks/headers_insert.rb +20 -0
- data/lib/sqreen/rules_callbacks/inspect_rule.rb +20 -0
- data/lib/sqreen/rules_callbacks/matcher_rule.rb +103 -0
- data/lib/sqreen/rules_callbacks/rails_parameters.rb +14 -0
- data/lib/sqreen/rules_callbacks/record_request_context.rb +23 -0
- data/lib/sqreen/rules_callbacks/reflected_xss.rb +40 -0
- data/lib/sqreen/rules_callbacks/regexp_rule.rb +36 -0
- data/lib/sqreen/rules_callbacks/shell.rb +33 -0
- data/lib/sqreen/rules_callbacks/shell_env.rb +32 -0
- data/lib/sqreen/rules_callbacks/sql.rb +41 -0
- data/lib/sqreen/rules_callbacks/system_shell.rb +25 -0
- data/lib/sqreen/rules_callbacks/url_matches.rb +25 -0
- data/lib/sqreen/rules_callbacks/user_agent_matches.rb +22 -0
- data/lib/sqreen/rules_signature.rb +142 -0
- data/lib/sqreen/runner.rb +312 -0
- data/lib/sqreen/runtime_infos.rb +127 -0
- data/lib/sqreen/session.rb +340 -0
- data/lib/sqreen/stats.rb +18 -0
- data/lib/sqreen/version.rb +6 -0
- metadata +95 -34
@@ -0,0 +1,22 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
module Sqreen
|
5
|
+
# Context
|
6
|
+
class Context
|
7
|
+
attr_accessor :bt
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@bt = get_current_backtrace
|
11
|
+
end
|
12
|
+
|
13
|
+
def get_current_backtrace
|
14
|
+
# Force caller to be resolved now
|
15
|
+
caller.map(&:to_s)
|
16
|
+
end
|
17
|
+
|
18
|
+
def ==(other)
|
19
|
+
other.bt == @bt
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
require 'sqreen/deliveries/simple'
|
5
|
+
require 'sqreen/events/remote_exception'
|
6
|
+
module Sqreen
|
7
|
+
module Deliveries
|
8
|
+
# Simple delivery method that batch event already seen in a batch
|
9
|
+
class Batch < Simple
|
10
|
+
attr_accessor :max_batch, :max_staleness
|
11
|
+
attr_accessor :current_batch
|
12
|
+
|
13
|
+
def initialize(session,
|
14
|
+
max_batch,
|
15
|
+
max_staleness,
|
16
|
+
randomize_staleness = true)
|
17
|
+
super(session)
|
18
|
+
self.max_batch = max_batch
|
19
|
+
self.max_staleness = max_staleness
|
20
|
+
@original_max_staleness = max_staleness
|
21
|
+
self.current_batch = []
|
22
|
+
@first_seen = {}
|
23
|
+
@randomize_staleness = randomize_staleness
|
24
|
+
end
|
25
|
+
|
26
|
+
def post_event(event)
|
27
|
+
current_batch.push(event)
|
28
|
+
post_batch if post_batch_needed?(event)
|
29
|
+
end
|
30
|
+
|
31
|
+
def drain
|
32
|
+
post_batch
|
33
|
+
end
|
34
|
+
|
35
|
+
def tick
|
36
|
+
post_batch if current_batch.size > 0 && stale?
|
37
|
+
end
|
38
|
+
|
39
|
+
protected
|
40
|
+
|
41
|
+
def stale?
|
42
|
+
min = @first_seen.values.min
|
43
|
+
(min + max_staleness) < Time.now
|
44
|
+
end
|
45
|
+
|
46
|
+
def post_batch_needed?(event)
|
47
|
+
key = event_key(event)
|
48
|
+
was = @first_seen[key]
|
49
|
+
now = Time.now
|
50
|
+
@first_seen[key] ||= now
|
51
|
+
return true if was.nil?
|
52
|
+
return true if current_batch.size > max_batch
|
53
|
+
return true if (was + max_staleness) < now
|
54
|
+
false
|
55
|
+
end
|
56
|
+
|
57
|
+
def post_batch
|
58
|
+
session.post_batch(current_batch)
|
59
|
+
current_batch.clear
|
60
|
+
now = Time.now
|
61
|
+
@first_seen.each_key do |key|
|
62
|
+
@first_seen[key] = now
|
63
|
+
end
|
64
|
+
return unless @randomize_staleness
|
65
|
+
self.max_staleness = @original_max_staleness
|
66
|
+
# Adds up to 10% of lateness
|
67
|
+
self.max_staleness += rand(@original_max_staleness / 10)
|
68
|
+
end
|
69
|
+
|
70
|
+
def event_key(event)
|
71
|
+
case event
|
72
|
+
when Sqreen::Attack
|
73
|
+
return "att-#{event.type}"
|
74
|
+
when Sqreen::RemoteException
|
75
|
+
return "rex-#{event.klass}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
require 'sqreen/events/remote_exception'
|
5
|
+
|
6
|
+
module Sqreen
|
7
|
+
module Deliveries
|
8
|
+
# Simple delivery method that directly call session on event
|
9
|
+
class Simple
|
10
|
+
attr_accessor :session
|
11
|
+
|
12
|
+
def initialize(session)
|
13
|
+
self.session = session
|
14
|
+
end
|
15
|
+
|
16
|
+
def post_event(event)
|
17
|
+
case event
|
18
|
+
when Sqreen::Attack
|
19
|
+
session.post_attack(event)
|
20
|
+
when Sqreen::RemoteException
|
21
|
+
session.post_sqreen_exception(event)
|
22
|
+
else
|
23
|
+
session.post_event(event)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def drain
|
28
|
+
# Since everything is posted at once nothing needs to be done here
|
29
|
+
end
|
30
|
+
|
31
|
+
def tick
|
32
|
+
# Since everything is posted at once nothing needs to be done here
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
require 'sqreen/detect/sql_injection'
|
5
|
+
require 'sqreen/detect/shell_injection'
|
6
|
+
|
7
|
+
module Sqreen
|
8
|
+
module Detect
|
9
|
+
def sql_injection?(request, params, db_type, db_infos = {})
|
10
|
+
inj = SQLInjection.new(db_type, db_infos)
|
11
|
+
inj.user_escape?(request, params)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
require 'sqreen/parsers/unix'
|
5
|
+
|
6
|
+
module Sqreen
|
7
|
+
module Detect
|
8
|
+
# Detector class for shell injections
|
9
|
+
# Find instance of user parameters injections into executable commands
|
10
|
+
# It work by:
|
11
|
+
# 1 - Highlighting the cmd for executable sections
|
12
|
+
# 2 - Highlighting the cmd for traces of user parameters
|
13
|
+
# 3 - Comparing if there is any intersection
|
14
|
+
class ShellInjection
|
15
|
+
def initialize
|
16
|
+
@parser = Sqreen::Parsers::Unix.new
|
17
|
+
end
|
18
|
+
|
19
|
+
# Is there a user injection in cmd
|
20
|
+
# @param cmd [String] command to analyze
|
21
|
+
# @param params [Hash] Hash of user parameters
|
22
|
+
def user_escape?(cmd, params)
|
23
|
+
Sqreen.log.info format('escape? %s', [cmd, params].inspect)
|
24
|
+
|
25
|
+
# We found the user query inside the cmd. A risk exists.
|
26
|
+
@parser.parse(cmd)
|
27
|
+
execs = @parser.atoms.select(&:executable?)
|
28
|
+
|
29
|
+
each_param_scalar(params) do |v|
|
30
|
+
next unless v
|
31
|
+
value = v.to_s
|
32
|
+
next unless value.size > 0
|
33
|
+
offset = 0
|
34
|
+
loop do
|
35
|
+
match_start = cmd.index(value, offset)
|
36
|
+
break if match_start.nil?
|
37
|
+
match_end = match_start + value.size
|
38
|
+
offset = match_end
|
39
|
+
covered = execs.any? do |exec|
|
40
|
+
match_end >= exec.start && match_start < exec.end
|
41
|
+
end
|
42
|
+
next unless covered
|
43
|
+
Sqreen.log.info format('injection for parameter %s', value.inspect)
|
44
|
+
return true
|
45
|
+
end
|
46
|
+
end
|
47
|
+
false
|
48
|
+
end
|
49
|
+
|
50
|
+
# FIXME: deduplicate code
|
51
|
+
def each_param_scalar(params, &block)
|
52
|
+
case params
|
53
|
+
when Hash then params.each { |_k, v| each_param_scalar(v, &block) }
|
54
|
+
when Array then params.each { |v| each_param_scalar(v, &block) }
|
55
|
+
else
|
56
|
+
yield params
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
require 'sqreen/parsers/sql'
|
5
|
+
require 'strscan'
|
6
|
+
|
7
|
+
module Sqreen
|
8
|
+
module Detect
|
9
|
+
class SQLInjection
|
10
|
+
PARAM_SIZE_LIMIT = 0
|
11
|
+
|
12
|
+
def self.parser(db_type, db_infos)
|
13
|
+
@parsers ||= {}
|
14
|
+
res = nil
|
15
|
+
results = @parsers[db_type]
|
16
|
+
res = results.find { |infos, _| infos == db_infos } unless results.nil?
|
17
|
+
return res.last unless res.nil?
|
18
|
+
@parsers[db_type] ||= []
|
19
|
+
parser = Sqreen::Parsers::SQL.new(db_type, db_infos)
|
20
|
+
@parsers[db_type] << [db_infos, parser]
|
21
|
+
parser
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_accessor :db_type, :db_infos
|
25
|
+
def initialize(db_type, db_infos)
|
26
|
+
@db_type = db_type
|
27
|
+
@db_infos = db_infos
|
28
|
+
@parser = SQLInjection.parser(db_type, db_infos)
|
29
|
+
end
|
30
|
+
|
31
|
+
# FIXME: we are likely to find false postive
|
32
|
+
# As is, high risk of false positive with extremely short parameters.
|
33
|
+
# E.g. if parameter value is 'e', it will be found in request, (e in
|
34
|
+
# select) but not in literals.
|
35
|
+
#
|
36
|
+
# We may want to skip:
|
37
|
+
# - too short parameters (e.g. < 10 letters?)
|
38
|
+
# - non suspicious parameters (e.g. without blanks or comments?)
|
39
|
+
def user_escape?(request, params)
|
40
|
+
included = count_user_params_in_request(request, params)
|
41
|
+
return false if included == {}
|
42
|
+
|
43
|
+
escape_found = false
|
44
|
+
|
45
|
+
# We found the user query inside the request. A risk exists.
|
46
|
+
@parser.parse(request)
|
47
|
+
literals = @parser.atoms.select(&:is_literal?)
|
48
|
+
included.each do |param, expected_count|
|
49
|
+
param_count = 0
|
50
|
+
literals.each do |literal|
|
51
|
+
# Count number of literals that fully include the user query
|
52
|
+
param_count += count_substring_nb(literal.val, param)
|
53
|
+
end
|
54
|
+
|
55
|
+
# puts "%s in raw request: %d, in atoms: %d" % [param, expected_count, param_count]
|
56
|
+
next unless param_count != expected_count
|
57
|
+
Sqreen.log.info format('injection for parameter %s', param.inspect)
|
58
|
+
# require request aborption
|
59
|
+
# log attack
|
60
|
+
escape_found = true
|
61
|
+
end
|
62
|
+
escape_found
|
63
|
+
end
|
64
|
+
|
65
|
+
# What if a string can be prefixed itself?
|
66
|
+
# E.g. substr = 'a b c a b c'
|
67
|
+
# If str = 'a b c a b c a b c' we will return 2:
|
68
|
+
# \----1----/
|
69
|
+
# \----2----/
|
70
|
+
def count_substring_nb(str, substr)
|
71
|
+
s = StringScanner.new(str)
|
72
|
+
nb = 0
|
73
|
+
quote = Regexp.quote(substr)
|
74
|
+
re = Regexp.new(quote)
|
75
|
+
nb += 1 while s.search_full(re, true, false)
|
76
|
+
nb
|
77
|
+
end
|
78
|
+
|
79
|
+
def each_param_scalar(params, &block)
|
80
|
+
case params
|
81
|
+
when Hash then params.each { |_k, v| each_param_scalar(v, &block) }
|
82
|
+
when Array then params.each { |v| each_param_scalar(v, &block) }
|
83
|
+
else
|
84
|
+
yield params
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# FIXME: this work on params values. We might wnat to work on parameters
|
89
|
+
# names? High risk of false positive since a parameter name is often the
|
90
|
+
# database column name.
|
91
|
+
def count_user_params_in_request(request, params_hash)
|
92
|
+
res = {}
|
93
|
+
params_hash.each do |_type, params|
|
94
|
+
next if params.nil?
|
95
|
+
each_param_scalar(params) do |value|
|
96
|
+
next unless value
|
97
|
+
v = value.to_s
|
98
|
+
next if v.size <= PARAM_SIZE_LIMIT
|
99
|
+
next if v =~ /\A\.+\z/
|
100
|
+
next if v =~ /\A\s+\z/
|
101
|
+
next if v =~ /\A(\w+|\w[\w\.]+\w)\z/i
|
102
|
+
|
103
|
+
# We need to overwrite the count of equal parameters that
|
104
|
+
# came from different ways (e.g. Cookie and query).
|
105
|
+
next if res.key? v
|
106
|
+
nb = count_substring_nb(request, v)
|
107
|
+
|
108
|
+
res[v] = nb if nb > 0
|
109
|
+
end
|
110
|
+
end
|
111
|
+
res
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
data/lib/sqreen/event.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
module Sqreen
|
5
|
+
# Master interface for point in time events (e.g. Attack, RemoteException)
|
6
|
+
class Event
|
7
|
+
attr_reader :payload
|
8
|
+
def initialize(payload)
|
9
|
+
@payload = payload
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_hash
|
13
|
+
payload.to_hash
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
require 'sqreen/event'
|
5
|
+
|
6
|
+
module Sqreen
|
7
|
+
# Attack
|
8
|
+
# When creating a new attack, it gets automatically pushed to the event's
|
9
|
+
# queue.
|
10
|
+
class Attack < Event
|
11
|
+
def self.record(payload)
|
12
|
+
attack = Attack.new(payload)
|
13
|
+
attack.enqueue
|
14
|
+
end
|
15
|
+
|
16
|
+
def infos
|
17
|
+
payload['infos']
|
18
|
+
end
|
19
|
+
|
20
|
+
def rulespack_id
|
21
|
+
return nil unless payload['rule']
|
22
|
+
payload['rule']['rulespack_id']
|
23
|
+
end
|
24
|
+
|
25
|
+
def type
|
26
|
+
return nil unless payload['rule']
|
27
|
+
payload['rule']['name']
|
28
|
+
end
|
29
|
+
|
30
|
+
def time
|
31
|
+
return nil unless payload['local']
|
32
|
+
payload['local']['time'].to_s
|
33
|
+
end
|
34
|
+
|
35
|
+
def backtrace
|
36
|
+
return nil unless payload['context']
|
37
|
+
payload['context']['backtrace']
|
38
|
+
end
|
39
|
+
|
40
|
+
def enqueue
|
41
|
+
Sqreen.queue.push(self)
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_hash
|
45
|
+
res = {}
|
46
|
+
rule_p = payload['rule']
|
47
|
+
request_p = payload['request']
|
48
|
+
res[:rule_name] = rule_p['name'] if rule_p && rule_p['name']
|
49
|
+
res[:rulespack_id] = rule_p['rulespack_id'] if rule_p && rule_p['rulespack_id']
|
50
|
+
res[:test] = rule_p['test'] if rule_p && rule_p['test']
|
51
|
+
res[:infos] = payload['infos'] if payload['infos']
|
52
|
+
res[:time] = time if time
|
53
|
+
res[:client_ip] = request_p[:addr] if request_p && request_p[:addr]
|
54
|
+
res[:request] = request_p if request_p
|
55
|
+
res[:params] = payload['params'] if payload['params']
|
56
|
+
res[:context] = payload['context'] if payload['context']
|
57
|
+
res
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# Copyright (c) 2015 Sqreen. All Rights Reserved.
|
2
|
+
# Please refer to our terms for more information: https://www.sqreen.io/terms.html
|
3
|
+
|
4
|
+
require 'json'
|
5
|
+
require 'sqreen/event'
|
6
|
+
|
7
|
+
module Sqreen
|
8
|
+
# When an exception arise it is automatically pushed to the event queue
|
9
|
+
class RemoteException < Sqreen::Event
|
10
|
+
def self.record(payload_or_exception)
|
11
|
+
exception = RemoteException.new(payload_or_exception)
|
12
|
+
exception.enqueue
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(payload_or_exception)
|
16
|
+
payload = if payload_or_exception.is_a?(Hash)
|
17
|
+
payload_or_exception
|
18
|
+
else
|
19
|
+
{ 'exception' => payload_or_exception }
|
20
|
+
end
|
21
|
+
super(payload)
|
22
|
+
end
|
23
|
+
|
24
|
+
def enqueue
|
25
|
+
Sqreen.queue.push(self)
|
26
|
+
end
|
27
|
+
|
28
|
+
def klass
|
29
|
+
payload['exception'].class.name
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_hash
|
33
|
+
exception = payload['exception']
|
34
|
+
ev = {
|
35
|
+
:klass => exception.class.name,
|
36
|
+
:message => exception.message,
|
37
|
+
:params => payload['request_params'],
|
38
|
+
:time => payload['time'],
|
39
|
+
:infos => {
|
40
|
+
:client_ip => payload['client_ip'],
|
41
|
+
},
|
42
|
+
:request => payload['request_infos'],
|
43
|
+
:rule_name => payload['rule_name'],
|
44
|
+
:rulespack_id => payload['rulespack_id'],
|
45
|
+
}
|
46
|
+
|
47
|
+
ev[:infos].merge!(payload['infos']) if payload['infos']
|
48
|
+
return ev unless exception.backtrace
|
49
|
+
ev[:context] = { :backtrace => exception.backtrace.map(&:to_s) }
|
50
|
+
ev
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|