datadog 2.36.0 → 2.37.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 +4 -4
- data/CHANGELOG.md +46 -1
- data/ext/datadog_profiling_native_extension/clock_id_from_mach.c +5 -12
- data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +6 -3
- data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +17 -3
- data/ext/datadog_profiling_native_extension/collectors_thread_context.c +219 -98
- data/ext/datadog_profiling_native_extension/collectors_thread_context.h +2 -0
- data/ext/datadog_profiling_native_extension/extconf.rb +11 -0
- data/ext/datadog_profiling_native_extension/ruby_helpers.c +7 -0
- data/ext/libdatadog_api/di.c +76 -0
- data/ext/libdatadog_api/extconf.rb +6 -0
- data/ext/libdatadog_extconf_helpers.rb +1 -1
- data/lib/datadog/ai_guard/api_client.rb +6 -4
- data/lib/datadog/appsec/api_security/route_extractor.rb +7 -4
- data/lib/datadog/appsec/configuration.rb +6 -0
- data/lib/datadog/appsec/context.rb +4 -2
- data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
- data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
- data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +1 -1
- data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +4 -0
- data/lib/datadog/appsec/contrib/rack/input_peeker.rb +4 -17
- data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
- data/lib/datadog/appsec/contrib/rails/gateway/request.rb +2 -1
- data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
- data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +4 -0
- data/lib/datadog/appsec/ext.rb +1 -0
- data/lib/datadog/appsec/metrics/collector.rb +18 -1
- data/lib/datadog/appsec/metrics/exporter.rb +9 -3
- data/lib/datadog/appsec/remote.rb +9 -3
- data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
- data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
- data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
- data/lib/datadog/appsec/route_normalizer.rb +80 -0
- data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
- data/lib/datadog/core/configuration/components.rb +82 -2
- data/lib/datadog/core/configuration/components_state.rb +6 -1
- data/lib/datadog/core/configuration/settings.rb +0 -3
- data/lib/datadog/core/configuration/supported_configurations.rb +2 -0
- data/lib/datadog/core/evp.rb +11 -0
- data/lib/datadog/core/remote/client/capabilities.rb +34 -7
- data/lib/datadog/core/telemetry/event/app_started.rb +8 -1
- data/lib/datadog/di/base.rb +3 -1
- data/lib/datadog/di/code_tracker.rb +5 -2
- data/lib/datadog/di/component.rb +110 -36
- data/lib/datadog/di/error.rb +10 -0
- data/lib/datadog/di/fatal_exceptions.rb +26 -0
- data/lib/datadog/di/instrumenter.rb +381 -165
- data/lib/datadog/di/probe.rb +14 -0
- data/lib/datadog/di/probe_file_loader.rb +8 -3
- data/lib/datadog/di/probe_manager.rb +67 -9
- data/lib/datadog/di/probe_notification_builder.rb +4 -1
- data/lib/datadog/di/probe_notifier_worker.rb +52 -32
- data/lib/datadog/di/redactor.rb +16 -1
- data/lib/datadog/di/remote.rb +175 -14
- data/lib/datadog/di/serializer.rb +12 -5
- data/lib/datadog/di/transport/input.rb +8 -4
- data/lib/datadog/di.rb +63 -0
- data/lib/datadog/kit/appsec/events/v2.rb +60 -2
- data/lib/datadog/open_feature/component.rb +29 -6
- data/lib/datadog/open_feature/configuration.rb +8 -0
- data/lib/datadog/open_feature/ext.rb +2 -0
- data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
- data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
- data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
- data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb → flag_eval_metrics_hook.rb} +1 -1
- data/lib/datadog/open_feature/native_evaluator.rb +22 -0
- data/lib/datadog/open_feature/provider.rb +59 -27
- data/lib/datadog/open_feature/transport.rb +55 -1
- data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
- data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
- data/lib/datadog/opentelemetry/sdk.rb +19 -0
- data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
- data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
- data/lib/datadog/symbol_database/component.rb +164 -47
- data/lib/datadog/symbol_database/configuration.rb +12 -4
- data/lib/datadog/symbol_database/extensions.rb +19 -0
- data/lib/datadog/symbol_database/extractor.rb +124 -87
- data/lib/datadog/symbol_database/file_hash.rb +3 -1
- data/lib/datadog/symbol_database/remote.rb +8 -3
- data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
- data/lib/datadog/symbol_database/uploader.rb +9 -2
- data/lib/datadog/symbol_database.rb +22 -0
- data/lib/datadog/tracing/distributed/baggage.rb +2 -1
- data/lib/datadog/tracing/ext.rb +9 -0
- data/lib/datadog/tracing/remote.rb +34 -3
- data/lib/datadog/version.rb +1 -1
- data/lib/datadog.rb +1 -0
- metadata +19 -6
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
require_relative '../../event'
|
|
4
4
|
require_relative '../../trace_keeper'
|
|
5
5
|
require_relative '../../security_event'
|
|
6
|
-
require_relative '../../utils/http/media_type'
|
|
7
6
|
require_relative '../../utils/http/body'
|
|
7
|
+
require_relative '../../utils/http/body_reader'
|
|
8
|
+
require_relative '../../utils/http/media_type'
|
|
8
9
|
|
|
9
10
|
module Datadog
|
|
10
11
|
module AppSec
|
|
@@ -35,7 +36,7 @@ module Datadog
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
if !is_redirect && sample_body
|
|
38
|
-
body =
|
|
39
|
+
body = parse_request_body(payload, content_type: headers['content-type'])
|
|
39
40
|
ephemeral_data['server.io.net.request.body'] = body if body
|
|
40
41
|
end
|
|
41
42
|
|
|
@@ -74,7 +75,7 @@ module Datadog
|
|
|
74
75
|
context.state[:downstream_redirect_url] = URI.join(url, headers['location']).to_s if is_redirect && sample_body
|
|
75
76
|
|
|
76
77
|
if sample_body && !is_redirect
|
|
77
|
-
body =
|
|
78
|
+
body = parse_response_body(response.body, headers: headers, context: context)
|
|
78
79
|
ephemeral_data['server.io.net.response.body'] = body if body
|
|
79
80
|
end
|
|
80
81
|
|
|
@@ -94,13 +95,65 @@ module Datadog
|
|
|
94
95
|
true
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
def
|
|
98
|
-
return if body.empty?
|
|
99
|
-
|
|
98
|
+
def parse_request_body(body, content_type:)
|
|
100
99
|
media_type = Utils::HTTP::MediaType.parse(content_type)
|
|
101
100
|
return unless media_type
|
|
102
101
|
|
|
103
|
-
|
|
102
|
+
# NOTE: Request body analysis is best-effort, non-rewindable payloads are skipped
|
|
103
|
+
limit = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
|
|
104
|
+
content = read_body(body, limit: limit)
|
|
105
|
+
return if content.nil? || content.bytesize > limit
|
|
106
|
+
|
|
107
|
+
Utils::HTTP::Body.parse(content, media_type: media_type)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_response_body(body, headers:, context:)
|
|
111
|
+
return unless readable_body?(body)
|
|
112
|
+
|
|
113
|
+
media_type = Utils::HTTP::MediaType.parse(headers['content-type'])
|
|
114
|
+
if !media_type || media_type.type != 'application'
|
|
115
|
+
context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
subtype = media_type.subtype
|
|
120
|
+
if subtype != 'json' && !subtype.end_with?('+json') && subtype != 'x-www-form-urlencoded'
|
|
121
|
+
context.metrics.record_ignored_downstream_response_body(:content_type_invalid)
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
content_length_value = headers['content-length']
|
|
126
|
+
if !content_length_value.is_a?(String) || !content_length_value.match?(/\A[1-9][0-9]*\z/)
|
|
127
|
+
context.metrics.record_ignored_downstream_response_body(:content_length_missing)
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
content_length = content_length_value.to_i
|
|
132
|
+
max = Datadog.configuration.appsec.api_security.downstream_body_analysis.max_downstream_body_bytes
|
|
133
|
+
if content_length > max
|
|
134
|
+
context.metrics.record_ignored_downstream_response_body(:content_length_too_big)
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
content = read_body(body, limit: content_length)
|
|
139
|
+
return if content.nil?
|
|
140
|
+
|
|
141
|
+
if content.bytesize > content_length
|
|
142
|
+
context.metrics.record_ignored_downstream_response_body(:content_exceed_content_length)
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
Utils::HTTP::Body.parse(content, media_type: media_type)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def readable_body?(body)
|
|
150
|
+
body.is_a?(String) || (body.respond_to?(:read) && body.respond_to?(:rewind))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def read_body(body, limit:)
|
|
154
|
+
Utils::HTTP::BodyReader.read(body, limit: limit, rewind_before_read: true)
|
|
155
|
+
rescue
|
|
156
|
+
nil
|
|
104
157
|
end
|
|
105
158
|
|
|
106
159
|
# NOTE: Starting version 2.1.0 headers are already normalized via internal
|
|
@@ -44,6 +44,10 @@ module Datadog
|
|
|
44
44
|
body = gateway_request.form_hash
|
|
45
45
|
persistent_data['server.request.body'] = body if body
|
|
46
46
|
end
|
|
47
|
+
# NOTE: Body was parsed before measurement, keep byte_length unset
|
|
48
|
+
elsif gateway_request.env.key?('rack.request.form_hash')
|
|
49
|
+
body = gateway_request.env['rack.request.form_hash']
|
|
50
|
+
persistent_data['server.request.body'] = body if body
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
next stack.call(request) if persistent_data.empty?
|
data/lib/datadog/appsec/ext.rb
CHANGED
|
@@ -17,7 +17,15 @@ module Datadog
|
|
|
17
17
|
keyword_init: true
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
DownstreamResponseStore = Struct.new(
|
|
21
|
+
:content_type_invalid,
|
|
22
|
+
:content_length_missing,
|
|
23
|
+
:content_length_too_big,
|
|
24
|
+
:content_exceed_content_length,
|
|
25
|
+
keyword_init: true
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
attr_reader :waf, :rasp, :downstream_responses
|
|
21
29
|
|
|
22
30
|
def initialize
|
|
23
31
|
@mutex = Mutex.new
|
|
@@ -31,6 +39,11 @@ module Datadog
|
|
|
31
39
|
evals: 0, matches: 0, errors: 0, timeouts: 0, duration_ns: 0,
|
|
32
40
|
duration_ext_ns: 0, inputs_truncated: 0, downstream_requests: 0
|
|
33
41
|
)
|
|
42
|
+
|
|
43
|
+
@downstream_responses = DownstreamResponseStore.new(
|
|
44
|
+
content_type_invalid: 0, content_length_missing: 0,
|
|
45
|
+
content_length_too_big: 0, content_exceed_content_length: 0
|
|
46
|
+
)
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
def record_waf(result)
|
|
@@ -57,6 +70,10 @@ module Datadog
|
|
|
57
70
|
@rasp.downstream_requests += 1 if type == Ext::RASP_SSRF && phase == Ext::RASP_REQUEST_PHASE
|
|
58
71
|
end
|
|
59
72
|
end
|
|
73
|
+
|
|
74
|
+
def record_ignored_downstream_response_body(reason)
|
|
75
|
+
@mutex.synchronize { @downstream_responses[reason] += 1 }
|
|
76
|
+
end
|
|
60
77
|
end
|
|
61
78
|
end
|
|
62
79
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Datadog
|
|
4
4
|
module AppSec
|
|
5
5
|
module Metrics
|
|
6
|
-
# A class responsible for exporting WAF and RASP call metrics
|
|
6
|
+
# A class responsible for exporting WAF and RASP call metrics
|
|
7
7
|
module Exporter
|
|
8
8
|
module_function
|
|
9
9
|
|
|
@@ -31,9 +31,15 @@ module Datadog
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
def export_downstream_response_metrics(metrics, span)
|
|
35
|
+
metrics.each_pair do |reason, count|
|
|
36
|
+
next if count.zero?
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
span.set_tag("_dd.appsec.downstream_request.response_body_ignored.#{reason}", count)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private_class_method def convert_ns_to_us(value)
|
|
37
43
|
value / 1000.0
|
|
38
44
|
end
|
|
39
45
|
end
|
|
@@ -78,17 +78,23 @@ module Datadog
|
|
|
78
78
|
engine = AppSec.security_engine
|
|
79
79
|
next unless engine
|
|
80
80
|
|
|
81
|
+
# We must process delete operations before insert and update operations
|
|
82
|
+
# to prevent duplicate-rule errors when the config name changes
|
|
83
|
+
changes.each do |change|
|
|
84
|
+
next unless change.type == :delete
|
|
85
|
+
|
|
86
|
+
engine.remove_config_at_path(change.path.to_s)
|
|
87
|
+
end
|
|
88
|
+
|
|
81
89
|
changes.each do |change|
|
|
82
90
|
content = repository[change.path]
|
|
83
|
-
next unless content
|
|
91
|
+
next unless content
|
|
84
92
|
|
|
85
93
|
case change.type
|
|
86
94
|
when :insert, :update
|
|
87
95
|
# @type var content: Core::Remote::Configuration::Content
|
|
88
96
|
engine.add_or_update_config(parse_content(content), path: change.path.to_s)
|
|
89
97
|
content.applied
|
|
90
|
-
when :delete
|
|
91
|
-
engine.remove_config_at_path(change.path.to_s)
|
|
92
98
|
end
|
|
93
99
|
end
|
|
94
100
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'route_text'
|
|
4
|
+
require_relative 'route_pattern'
|
|
5
|
+
|
|
6
|
+
module Datadog
|
|
7
|
+
module AppSec
|
|
8
|
+
module RouteNormalizer
|
|
9
|
+
# Normalizes Rails route patterns into route format, inspired by
|
|
10
|
+
# OpenAPI v3 path templating
|
|
11
|
+
#
|
|
12
|
+
# NOTE: Uses the parsed Journey AST when available
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class RailsRoutePattern
|
|
16
|
+
DOT_CHAR = '.'
|
|
17
|
+
GROUP_OPEN_CHAR = '('
|
|
18
|
+
OPTIONAL_GROUP_PATTERN = /\(([^()]*)\)/
|
|
19
|
+
NAMED_PARAM_PREFIX_CHAR = ':'
|
|
20
|
+
GLOB_PARAM_PREFIX_CHAR = '*'
|
|
21
|
+
|
|
22
|
+
def initialize(pattern)
|
|
23
|
+
@pattern = pattern
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def normalize(path_params:, path:)
|
|
27
|
+
@path_params = path_params
|
|
28
|
+
@path = path
|
|
29
|
+
|
|
30
|
+
if @pattern.is_a?(String)
|
|
31
|
+
# NOTE: Journey groups without route params are never kept
|
|
32
|
+
# Example: `/foo(/bar)` with request `/foo/bar` normalizes to `/foo`
|
|
33
|
+
pattern = remove_paramless_optional_groups(@pattern)
|
|
34
|
+
return RoutePattern.new(pattern).normalize(path: path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
route_path = @pattern.path
|
|
38
|
+
route_spec = route_path.spec
|
|
39
|
+
|
|
40
|
+
unless route_spec.respond_to?(:type)
|
|
41
|
+
return RoutePattern.new(route_spec.to_s).normalize(path: path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if route_path.names.empty?
|
|
45
|
+
route_string = route_spec.to_s
|
|
46
|
+
return RouteText.escape(route_string) unless route_string.include?(GROUP_OPEN_CHAR)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
buffer = Buffer.new
|
|
50
|
+
visit_route_node(route_spec, buffer)
|
|
51
|
+
buffer.to_path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def remove_paramless_optional_groups(pattern)
|
|
57
|
+
result = pattern
|
|
58
|
+
|
|
59
|
+
loop do
|
|
60
|
+
substituted = result.gsub(OPTIONAL_GROUP_PATTERN) do
|
|
61
|
+
# NOTE: OPTIONAL_GROUP_PATTERN always captures a string for each gsub match
|
|
62
|
+
group = ::Regexp.last_match(1) # : String
|
|
63
|
+
optional_group_has_route_params?(group) ? "(#{group})" : ''
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return result if substituted == result
|
|
67
|
+
|
|
68
|
+
result = substituted
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def visit_route_node(node, buffer)
|
|
73
|
+
case node.type
|
|
74
|
+
when :CAT
|
|
75
|
+
visit_route_node(node.left, buffer)
|
|
76
|
+
visit_route_node(node.right, buffer)
|
|
77
|
+
when :SLASH
|
|
78
|
+
buffer.flush
|
|
79
|
+
when :LITERAL
|
|
80
|
+
buffer.add_literal(node.left)
|
|
81
|
+
when :DOT
|
|
82
|
+
buffer.add_literal(DOT_CHAR)
|
|
83
|
+
when :SYMBOL, :STAR
|
|
84
|
+
buffer.add_param(node.name)
|
|
85
|
+
when :GROUP
|
|
86
|
+
visit_route_node(node.left, buffer) if group_present?(node.left)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def group_present?(node)
|
|
91
|
+
param_names = collect_group_param_names(node, [])
|
|
92
|
+
return false if param_names.empty?
|
|
93
|
+
|
|
94
|
+
param_names.all? { |name| param_matched_path?(name.to_sym) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def param_matched_path?(name)
|
|
98
|
+
return @path_params[name].is_a?(String) unless name == :format
|
|
99
|
+
|
|
100
|
+
format = @path_params[:format]
|
|
101
|
+
return false if !format.is_a?(String) || format.empty?
|
|
102
|
+
|
|
103
|
+
dot_index = @path.length - format.length - 1
|
|
104
|
+
return false if dot_index < 0 || @path[dot_index] != DOT_CHAR
|
|
105
|
+
|
|
106
|
+
@path.end_with?(format)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def collect_group_param_names(node, memo)
|
|
110
|
+
case node.type
|
|
111
|
+
when :SYMBOL, :STAR
|
|
112
|
+
memo << node.name
|
|
113
|
+
when :CAT
|
|
114
|
+
collect_group_param_names(node.left, memo)
|
|
115
|
+
collect_group_param_names(node.right, memo)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
memo
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def optional_group_has_route_params?(group)
|
|
122
|
+
group.include?(NAMED_PARAM_PREFIX_CHAR) || group.include?(GLOB_PARAM_PREFIX_CHAR)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class Buffer
|
|
126
|
+
def initialize
|
|
127
|
+
@segments = []
|
|
128
|
+
@text = +''
|
|
129
|
+
@params = []
|
|
130
|
+
@nameless_param_count = 0
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def add_literal(text)
|
|
134
|
+
@text << text
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def add_param(name)
|
|
138
|
+
@params << name
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def flush
|
|
142
|
+
return if @text.empty? && @params.empty?
|
|
143
|
+
|
|
144
|
+
@segments << if @params.empty?
|
|
145
|
+
RouteText.escape(@text)
|
|
146
|
+
else
|
|
147
|
+
render_params(@params)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@text = +''
|
|
151
|
+
@params.clear
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def to_path
|
|
155
|
+
flush
|
|
156
|
+
|
|
157
|
+
"/#{@segments.join('/')}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def render_params(params)
|
|
163
|
+
names = params.map do |name| # $ String
|
|
164
|
+
next name unless name.empty?
|
|
165
|
+
|
|
166
|
+
@nameless_param_count += 1
|
|
167
|
+
"param#{@nameless_param_count}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
"{#{names.join('+')}}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|