pentest 1.0.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/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +75 -0
- data/LICENSE.txt +21 -0
- data/README.md +59 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/pentest +6 -0
- data/lib/pentest.rb +77 -0
- data/lib/pentest/ast_utils.rb +76 -0
- data/lib/pentest/checkers.rb +21 -0
- data/lib/pentest/checkers/base_checker.rb +49 -0
- data/lib/pentest/checkers/sqli_checker.rb +226 -0
- data/lib/pentest/checkers/xss_checker.rb +87 -0
- data/lib/pentest/commandline.rb +41 -0
- data/lib/pentest/dsl.rb +15 -0
- data/lib/pentest/endpoint.rb +149 -0
- data/lib/pentest/fuzzers/sqli.txt +193 -0
- data/lib/pentest/fuzzers/xss.txt +164 -0
- data/lib/pentest/initializer.rb +8 -0
- data/lib/pentest/logger.rb +59 -0
- data/lib/pentest/payload.rb +76 -0
- data/lib/pentest/ruby_parser.rb +21 -0
- data/lib/pentest/runner.rb +58 -0
- data/lib/pentest/sql_proxy.rb +59 -0
- data/lib/pentest/version.rb +3 -0
- data/pentest.gemspec +50 -0
- metadata +218 -0
@@ -0,0 +1,226 @@
|
|
1
|
+
require 'pentest/checkers/base_checker'
|
2
|
+
require 'pentest/payload'
|
3
|
+
require 'pentest/sql_proxy'
|
4
|
+
require 'term/ansicolor'
|
5
|
+
require 'pairwise'
|
6
|
+
require 'arproxy'
|
7
|
+
require 'callsite'
|
8
|
+
require 'gda'
|
9
|
+
|
10
|
+
class Pentest::SqliChecker < Pentest::BaseChecker
|
11
|
+
Pentest::Checkers.add self
|
12
|
+
|
13
|
+
@description = "Checks for SQL injections"
|
14
|
+
|
15
|
+
SQLI_PAYLOADS = File.read(File.expand_path('../fuzzers/sqli.txt', File.dirname(__FILE__)), encoding: 'utf-8').lines.map(&:strip).select {|l| l.size > 5 && l =~ /\W/}
|
16
|
+
CRACKER_PAYLOAD = %q(<>"'%;()&+)
|
17
|
+
|
18
|
+
def initialize(endpoint, params)
|
19
|
+
super(endpoint, params)
|
20
|
+
@queries = []
|
21
|
+
@parser = GDA::SQL::Parser.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def generate_preattack_payloads(params, seeds, injection_point)
|
25
|
+
values_list = if params.size - 1 <= 0
|
26
|
+
[[]]
|
27
|
+
elsif params.size - 1 == 1
|
28
|
+
seeds.map {|s| [s]}
|
29
|
+
else
|
30
|
+
Pairwise.combinations(*([seeds] * (params.size - 1)))
|
31
|
+
end
|
32
|
+
|
33
|
+
values_list.map do |values|
|
34
|
+
values.insert(injection_point, CRACKER_PAYLOAD)
|
35
|
+
|
36
|
+
Pentest::Payload.new(
|
37
|
+
params: params,
|
38
|
+
route: @route,
|
39
|
+
values: values,
|
40
|
+
injection_point: injection_point,
|
41
|
+
injection: CRACKER_PAYLOAD,
|
42
|
+
)
|
43
|
+
end.take(50)
|
44
|
+
end
|
45
|
+
|
46
|
+
def handle_query(sql)
|
47
|
+
@queries << [sql, caller]
|
48
|
+
end
|
49
|
+
|
50
|
+
def attack(param, injection_point, ingredients)
|
51
|
+
preattack_payloads = generate_preattack_payloads(@params, ingredients, injection_point)
|
52
|
+
|
53
|
+
errors = []
|
54
|
+
|
55
|
+
penetrated_payload = nil
|
56
|
+
preattack_payloads.shuffle.each do |payload|
|
57
|
+
request, response, err = dispatch(payload)
|
58
|
+
status = get_status(err) || response.status
|
59
|
+
|
60
|
+
Pentest::Logger.put_progress (status / 100).to_s
|
61
|
+
|
62
|
+
errors << normalize_error(err, payload)
|
63
|
+
|
64
|
+
if ::ActiveRecord::StatementInvalid === err
|
65
|
+
payload.penetration_type = 'SQL Injection Vulnerability'
|
66
|
+
payload.penetration_confidence = :preattack
|
67
|
+
penetrated_payload = payload
|
68
|
+
break
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# preattack not succeeded. skipping
|
73
|
+
return [nil, errors] if penetrated_payload.nil?
|
74
|
+
|
75
|
+
# attack
|
76
|
+
attack_payloads = generate_attack_payloads(params, penetrated_payload.values, injection_point)
|
77
|
+
|
78
|
+
Pentest::SqlProxy.enable!(self.method(:handle_query))
|
79
|
+
|
80
|
+
attack_payloads.shuffle.each do |payload|
|
81
|
+
request, response, err = dispatch(payload)
|
82
|
+
status = get_status(err) || response.status
|
83
|
+
|
84
|
+
Pentest::Logger.put_progress (status / 100).to_s
|
85
|
+
|
86
|
+
errors << normalize_error(err, payload)
|
87
|
+
|
88
|
+
check_attack_result(payload, err);
|
89
|
+
|
90
|
+
if payload.penetration_confidence == :attack
|
91
|
+
penetrated_payload = payload
|
92
|
+
break
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
Pentest::SqlProxy.disable!(self.method(:handle_query))
|
97
|
+
|
98
|
+
[penetrated_payload, errors]
|
99
|
+
end
|
100
|
+
|
101
|
+
def generate_attack_payloads(params, values, injection_point)
|
102
|
+
SQLI_PAYLOADS.map do |injection|
|
103
|
+
new_values = values.dup
|
104
|
+
new_values[injection_point] = injection
|
105
|
+
|
106
|
+
Pentest::Payload.new(
|
107
|
+
params: params,
|
108
|
+
route: @route,
|
109
|
+
values: new_values,
|
110
|
+
injection_point: injection_point,
|
111
|
+
injection: injection,
|
112
|
+
)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def check_attack_result(payload, err)
|
117
|
+
@queries.each do |query, trace|
|
118
|
+
begin
|
119
|
+
stmt = @parser.parse(query.tr('`?', '"0'))
|
120
|
+
rescue RuntimeError => e
|
121
|
+
next
|
122
|
+
end
|
123
|
+
|
124
|
+
if query.include?(payload.injection) && !(GDA::Nodes::Unknown === stmt.ast)
|
125
|
+
tokens = extract_values(stmt.ast)
|
126
|
+
if tokens.all? {|token| !token.force_encoding("UTF-8").include? payload.injection}
|
127
|
+
callsites = Callsite.parse(trace)
|
128
|
+
project_call = callsites.find do |callsite|
|
129
|
+
callsite.filename.starts_with?(@app_path)
|
130
|
+
end
|
131
|
+
unless project_call.nil?
|
132
|
+
line = File.read(project_call.filename).lines[project_call.line - 1].rstrip
|
133
|
+
end
|
134
|
+
|
135
|
+
payload.penetration_type = 'SQL Injection Vulnerability'
|
136
|
+
payload.penetration_confidence = :attack
|
137
|
+
payload.penetration_message = [
|
138
|
+
*(line.nil? ? [] : [
|
139
|
+
"#{project_call.filename}:#{project_call.line}",
|
140
|
+
"> #{line}",
|
141
|
+
]),
|
142
|
+
"Issued query: #{query.gsub(payload.injection, Term::ANSIColor.red(payload.injection))}",
|
143
|
+
].join("\n")
|
144
|
+
break
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def extract_values(stmt)
|
151
|
+
ret = []
|
152
|
+
|
153
|
+
if stmt.nil?
|
154
|
+
# nop
|
155
|
+
elsif stmt.is_a?(String)
|
156
|
+
ret << stmt
|
157
|
+
elsif stmt.is_a?(Integer)
|
158
|
+
ret << stmt.to_s
|
159
|
+
elsif stmt.is_a?(Array)
|
160
|
+
stmt.each do |s|
|
161
|
+
ret += extract_values(s)
|
162
|
+
end
|
163
|
+
elsif GDA::Nodes::Table === stmt
|
164
|
+
ret += extract_values(stmt.table_name)
|
165
|
+
elsif GDA::Nodes::Field === stmt
|
166
|
+
ret += extract_values(stmt.field_name)
|
167
|
+
elsif GDA::Nodes::Expr === stmt
|
168
|
+
ret += extract_values(stmt.func)
|
169
|
+
ret += extract_values(stmt.cond)
|
170
|
+
ret += extract_values(stmt.select)
|
171
|
+
ret += extract_values(stmt.case_s)
|
172
|
+
ret += extract_values(stmt.param_spec)
|
173
|
+
ret += extract_values(stmt.cast_as)
|
174
|
+
ret << stmt.value
|
175
|
+
elsif GDA::Nodes::Select === stmt
|
176
|
+
ret += extract_values(stmt.distinct_expr)
|
177
|
+
ret += extract_values(stmt.expr_list)
|
178
|
+
ret += extract_values(stmt.from)
|
179
|
+
ret += extract_values(stmt.where_cond)
|
180
|
+
ret += extract_values(stmt.group_by)
|
181
|
+
ret += extract_values(stmt.having_cond)
|
182
|
+
ret += extract_values(stmt.order_by)
|
183
|
+
ret += extract_values(stmt.limit_count)
|
184
|
+
ret += extract_values(stmt.limit_offset)
|
185
|
+
elsif GDA::Nodes::SelectField === stmt
|
186
|
+
ret += extract_values(stmt.expr)
|
187
|
+
ret += extract_values(stmt.field_name)
|
188
|
+
ret += extract_values(stmt.table_name)
|
189
|
+
ret += extract_values(stmt.as)
|
190
|
+
elsif GDA::Nodes::From === stmt
|
191
|
+
ret += extract_values(stmt.targets)
|
192
|
+
ret += extract_values(stmt.joins)
|
193
|
+
elsif GDA::Nodes::Operation === stmt
|
194
|
+
ret += extract_values(stmt.operands)
|
195
|
+
elsif GDA::Nodes::Target === stmt
|
196
|
+
ret += extract_values(stmt.expr)
|
197
|
+
ret += extract_values(stmt.table_name)
|
198
|
+
ret += extract_values(stmt.as)
|
199
|
+
elsif GDA::Nodes::Function === stmt
|
200
|
+
ret += extract_values(stmt.args_list)
|
201
|
+
ret += extract_values(stmt.function_name)
|
202
|
+
elsif GDA::Nodes::Order === stmt
|
203
|
+
ret += extract_values(stmt.expr)
|
204
|
+
ret += extract_values(stmt.collation_name)
|
205
|
+
elsif GDA::Nodes::Insert === stmt
|
206
|
+
ret += extract_values(stmt.table)
|
207
|
+
ret += extract_values(stmt.fields_list)
|
208
|
+
ret += extract_values(stmt.expr_list)
|
209
|
+
ret += extract_values(stmt.cond)
|
210
|
+
ret += extract_values(stmt.conflict)
|
211
|
+
elsif GDA::Nodes::Delete === stmt
|
212
|
+
ret += extract_values(stmt.table)
|
213
|
+
ret += extract_values(stmt.cond)
|
214
|
+
elsif GDA::Nodes::Join === stmt
|
215
|
+
ret += extract_values(stmt.expr)
|
216
|
+
ret += extract_values(stmt.use)
|
217
|
+
ret += extract_values(stmt.position)
|
218
|
+
elsif GDA::Nodes::Compound === stmt
|
219
|
+
ret += extract_values(stmt.compound_type)
|
220
|
+
elsif GDA::Nodes::Unknown === stmt
|
221
|
+
ret += extract_values(stmt.expressions)
|
222
|
+
end
|
223
|
+
|
224
|
+
ret
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'pentest/checkers/base_checker'
|
2
|
+
require 'pentest/payload'
|
3
|
+
require 'term/ansicolor'
|
4
|
+
require 'pairwise'
|
5
|
+
|
6
|
+
class Pentest::XssChecker < Pentest::BaseChecker
|
7
|
+
Pentest::Checkers.add self
|
8
|
+
|
9
|
+
@description = "Checks for Cross-Site Scripting"
|
10
|
+
|
11
|
+
XSS_PAYLOADS = File.read(File.expand_path('../fuzzers/xss.txt', File.dirname(__FILE__)), encoding: 'utf-8').lines.map(&:strip).select {|l| l.size > 5 && l =~ /\W/}
|
12
|
+
CRACKER_PAYLOAD = %q(>>"<>=""'&<<"'&)
|
13
|
+
|
14
|
+
def initialize(endpoint, params)
|
15
|
+
super(endpoint, params)
|
16
|
+
end
|
17
|
+
|
18
|
+
def attack(param, injection_point, ingredients)
|
19
|
+
preattack_payloads = generate_preattack_payloads(@params, ingredients, injection_point)
|
20
|
+
|
21
|
+
errors = []
|
22
|
+
|
23
|
+
penetrated_payload = nil
|
24
|
+
preattack_payloads.shuffle.each do |payload|
|
25
|
+
request, response, err = dispatch(payload)
|
26
|
+
status = get_status(err) || response.status
|
27
|
+
|
28
|
+
Pentest::Logger.put_progress (status / 100).to_s
|
29
|
+
|
30
|
+
errors << normalize_error(err, payload)
|
31
|
+
document = Nokogiri::HTML(response.body)
|
32
|
+
document_errors = document.errors.reject {|e| is_allowable_error(e)}
|
33
|
+
|
34
|
+
if document_errors.any?
|
35
|
+
payload.penetration_type = 'Cross-Site Scripting Vulnerability'
|
36
|
+
payload.penetration_confidence = :preattack
|
37
|
+
payload.penetration_message = report_errors(response.body, document_errors)
|
38
|
+
penetrated_payload = payload
|
39
|
+
break
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
[penetrated_payload, errors]
|
44
|
+
end
|
45
|
+
|
46
|
+
def generate_preattack_payloads(params, seeds, injection_point)
|
47
|
+
values_list = if params.size - 1 <= 0
|
48
|
+
[[]]
|
49
|
+
elsif params.size - 1 == 1
|
50
|
+
seeds.map {|s| [s]}
|
51
|
+
else
|
52
|
+
Pairwise.combinations(*([seeds] * (params.size - 1)))
|
53
|
+
end
|
54
|
+
|
55
|
+
values_list.map do |values|
|
56
|
+
values.insert(injection_point, CRACKER_PAYLOAD)
|
57
|
+
|
58
|
+
Pentest::Payload.new(
|
59
|
+
params: params,
|
60
|
+
route: @route,
|
61
|
+
values: values,
|
62
|
+
injection_point: injection_point,
|
63
|
+
injection: CRACKER_PAYLOAD,
|
64
|
+
)
|
65
|
+
end.take(50)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def is_allowable_error(error)
|
71
|
+
error.to_s =~ /Tag \w+ invalid/ || error.to_s =~ /already defined/ || error.to_s =~ /Unexpected end tag/
|
72
|
+
end
|
73
|
+
|
74
|
+
def report_errors(body, errors)
|
75
|
+
body_lines = body.lines
|
76
|
+
|
77
|
+
error_strings = errors.map do |error|
|
78
|
+
lines = []
|
79
|
+
lines << error.to_s
|
80
|
+
lines << body_lines[error.line - 1].rstrip
|
81
|
+
lines << ' ' * (error.column - 1) + '^'
|
82
|
+
lines.join("\n")
|
83
|
+
end
|
84
|
+
|
85
|
+
error_strings.join("\n\n")
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module Pentest
|
4
|
+
# Implements Command-Line Interface of Pentest
|
5
|
+
|
6
|
+
class Commandline
|
7
|
+
class << self
|
8
|
+
# Runs everything:
|
9
|
+
def run default_app_path = "."
|
10
|
+
options, args = get_options
|
11
|
+
|
12
|
+
if args.size >= 1
|
13
|
+
options[:app_path] = args[0]
|
14
|
+
else
|
15
|
+
options[:app_path] = default_app_path
|
16
|
+
end
|
17
|
+
|
18
|
+
result = Pentest.run options.merge(:print_report => true)
|
19
|
+
|
20
|
+
if result.nil?
|
21
|
+
exit 0
|
22
|
+
else
|
23
|
+
exit 1
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def get_options
|
28
|
+
options = {}
|
29
|
+
parser = create_option_parser options
|
30
|
+
args = parser.parse! ARGV
|
31
|
+
[options, args]
|
32
|
+
end
|
33
|
+
|
34
|
+
def create_option_parser options
|
35
|
+
OptionParser.new do |opts|
|
36
|
+
opts.banner = "Usage: pentest [options] rails/root/path"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/pentest/dsl.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
module Pentest
|
2
|
+
module DSL
|
3
|
+
private
|
4
|
+
|
5
|
+
def pentest_setup(*args, &block)
|
6
|
+
Pentest::add_setup(*args, &block)
|
7
|
+
end
|
8
|
+
|
9
|
+
def pentest_before_attack(*args, &block)
|
10
|
+
Pentest::add_before_attack(*args, &block)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
self.extend Pentest::DSL
|
@@ -0,0 +1,149 @@
|
|
1
|
+
require 'pentest/checkers'
|
2
|
+
|
3
|
+
module Pentest
|
4
|
+
class Endpoint
|
5
|
+
attr_reader :route, :app_path
|
6
|
+
|
7
|
+
def initialize(route, app_path, hooks)
|
8
|
+
@route = route
|
9
|
+
@app_path = app_path
|
10
|
+
@hooks = hooks
|
11
|
+
|
12
|
+
@controller = route.defaults[:controller]
|
13
|
+
@action = route.defaults[:action]
|
14
|
+
|
15
|
+
return if @controller.nil? || @action.nil?
|
16
|
+
|
17
|
+
@controller_name = ::ActiveSupport::Inflector.camelize(@controller) + "Controller"
|
18
|
+
@controller_class = ::ActiveSupport::Inflector.constantize(@controller_name)
|
19
|
+
|
20
|
+
@error_patterns = Set.new
|
21
|
+
end
|
22
|
+
|
23
|
+
def valid?
|
24
|
+
!@controller.nil? && !@action.nil? && @controller_class.method_defined?(@action.to_sym)
|
25
|
+
end
|
26
|
+
|
27
|
+
def scan!(ingredients)
|
28
|
+
params = get_params
|
29
|
+
|
30
|
+
Logger.info "#{@route.verb} #{path}"
|
31
|
+
Logger.debug "Attacking #{@controller_class.inspect}##{@action}...", timestamp: false
|
32
|
+
Logger.debug "Detected Parameters: #{params.to_a.inspect}", timestamp: false
|
33
|
+
|
34
|
+
error_patterns = Set.new
|
35
|
+
penetrated_payloads = []
|
36
|
+
|
37
|
+
Logger.start_progress
|
38
|
+
|
39
|
+
Checkers.run_checkers(self, params) do |checker|
|
40
|
+
params.each_with_index do |param, injection_point|
|
41
|
+
penetrated_payload, errors = checker.attack(param, injection_point, ingredients)
|
42
|
+
|
43
|
+
accumulate_errors(errors)
|
44
|
+
|
45
|
+
unless penetrated_payload.nil?
|
46
|
+
penetrated_payloads << penetrated_payload
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
Logger.end_progress
|
52
|
+
|
53
|
+
@error_patterns.each do |error_pattern|
|
54
|
+
Logger.warn("Error: #{error_pattern}", timestamp: false)
|
55
|
+
end
|
56
|
+
|
57
|
+
penetrated_payloads
|
58
|
+
end
|
59
|
+
|
60
|
+
def dispatch(payload)
|
61
|
+
request = ActionDispatch::TestRequest.create
|
62
|
+
request.request_method = @route.verb
|
63
|
+
request.path = path(payload.params_hash)
|
64
|
+
|
65
|
+
@hooks[:before_attacks].each do |before_attack_proc|
|
66
|
+
before_attack_proc.call(request)
|
67
|
+
end
|
68
|
+
|
69
|
+
request.path_parameters = {
|
70
|
+
controller: @controller,
|
71
|
+
action: @action,
|
72
|
+
}
|
73
|
+
|
74
|
+
payload.params_hash.each do |param_parts, value|
|
75
|
+
if param_parts.size == 1
|
76
|
+
param, = param_parts
|
77
|
+
if @route.required_parts.include? param
|
78
|
+
request.path_parameters[param] = value
|
79
|
+
else
|
80
|
+
request.query_parameters[param] = value
|
81
|
+
end
|
82
|
+
elsif param_parts.size == 2
|
83
|
+
request.query_parameters[param_parts[0]] ||= {}
|
84
|
+
request.query_parameters[param_parts[0]][param_parts[1]] = value
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
request.path_parameters.each do |param, value|
|
89
|
+
request.update_param(param, value)
|
90
|
+
end
|
91
|
+
|
92
|
+
request.query_parameters.each do |param, value|
|
93
|
+
request.update_param(param, value)
|
94
|
+
end
|
95
|
+
|
96
|
+
response = ActionDispatch::TestResponse.create
|
97
|
+
|
98
|
+
err = nil
|
99
|
+
begin
|
100
|
+
@controller_class.new.dispatch(@action.to_sym, request, response)
|
101
|
+
rescue => e
|
102
|
+
err = e
|
103
|
+
end
|
104
|
+
|
105
|
+
[request, response, err]
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def method
|
111
|
+
@controller_class.instance_method(@action.to_sym)
|
112
|
+
end
|
113
|
+
|
114
|
+
def path(options = {})
|
115
|
+
@route.required_parts.each do |part|
|
116
|
+
options[part] ||= ":#{part}"
|
117
|
+
end
|
118
|
+
|
119
|
+
@route.format(options)
|
120
|
+
end
|
121
|
+
|
122
|
+
def get_params
|
123
|
+
exp = RubyParser.get_sexp(method)
|
124
|
+
param_usages = AstUtils.search_for_params(exp)
|
125
|
+
deep_parameters = Set.new
|
126
|
+
non_deep_parameters = Set.new
|
127
|
+
|
128
|
+
param_usages.each do |param, type, method, arg|
|
129
|
+
if type == :callee && method == :[]
|
130
|
+
deep_parameters << [ param, arg[1] ]
|
131
|
+
else
|
132
|
+
non_deep_parameters << param
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
non_deep_parameters += @route.required_parts.map(&:to_sym)
|
137
|
+
non_deep_parameters -= deep_parameters.map {|a| a[0]}
|
138
|
+
deep_parameters.to_a + non_deep_parameters.map {|param| [param]}
|
139
|
+
end
|
140
|
+
|
141
|
+
def accumulate_errors(errors)
|
142
|
+
errors.each do |error|
|
143
|
+
unless error.nil?
|
144
|
+
@error_patterns << error
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|