Skip to content

Commit 5626c9c

Browse files
committed
(GH-1759) Strip non-UTF-8 characters from command/script results
We explicitly fail if a task returns non-UTF-8 characters, but those characters are generally allowed from commands and scripts. However, they still can't be printed or converted to JSON. We now define a "safe_value" that is the scrubbed version of the input and use that for printing and JSON. This commit removes the status_hash method and replaces it in all cases with the to_data method which is otherwise identical except for having string keys. The to_data method now returns the safe_value, as to_data is only used to print the result, deal with it as JSON equivalent in Puppet, or emit it as JSON. Each of those cases requires it to be UTF-8 only. !bug * **Non-UTF-8 characters in command and script output are removed before printing** ([#1759](#1759)) Commands and scripts are allowed to return UTF-8, but Bolt would error when trying to print those results or return them as JSON. Now, accessing fields of the result from a Puppet plan will return the values unmodified, but invalid characters will be replaced by their hex-escaped equivalents when printing the result or converting it to JSON.
1 parent 62708be commit 5626c9c

File tree

8 files changed

+46
-37
lines changed

8 files changed

+46
-37
lines changed

lib/bolt/outputter/human.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,14 @@ def print_result(result)
107107

108108
# Use special handling if the result looks like a command or script result
109109
if result.generic_value.keys == %w[stdout stderr exit_code]
110-
unless result['stdout'].strip.empty?
110+
safe_value = result.safe_value
111+
unless safe_value['stdout'].strip.empty?
111112
@stream.puts(indent(2, "STDOUT:"))
112-
@stream.puts(indent(4, result['stdout']))
113+
@stream.puts(indent(4, safe_value['stdout']))
113114
end
114-
unless result['stderr'].strip.empty?
115+
unless safe_value['stderr'].strip.empty?
115116
@stream.puts(indent(2, "STDERR:"))
116-
@stream.puts(indent(4, result['stderr']))
117+
@stream.puts(indent(4, safe_value['stderr']))
117118
end
118119
elsif result.generic_value.any?
119120
@stream.puts(indent(2, ::JSON.pretty_generate(result.generic_value)))

lib/bolt/outputter/json.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def handle_event(event)
2828

2929
def print_result(result)
3030
@stream.puts ',' if @preceding_item
31-
@stream.puts result.status_hash.to_json
31+
@stream.puts result.to_json
3232
@preceding_item = true
3333
end
3434

lib/bolt/rerun.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def update(result_set)
4545
end
4646

4747
if result_set.is_a?(Bolt::ResultSet)
48-
data = result_set.map { |res| res.status_hash.select { |k, _| %i[target status].include? k } }
48+
data = result_set.map { |res| { target: res.target.name, status: res.status } }
4949
FileUtils.mkdir_p(File.dirname(@path))
5050
File.write(@path, data.to_json)
5151
elsif File.exist?(@path)

lib/bolt/result.rb

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -122,18 +122,8 @@ def message?
122122
message && !message.strip.empty?
123123
end
124124

125-
def status_hash
126-
{
127-
target: @target.name,
128-
action: action,
129-
object: object,
130-
status: status,
131-
value: @value
132-
}
133-
end
134-
135125
def generic_value
136-
value.reject { |k, _| %w[_error _output].include? k }
126+
safe_value.reject { |k, _| %w[_error _output].include? k }
137127
end
138128

139129
def eql?(other)
@@ -151,15 +141,36 @@ def ==(other)
151141
end
152142

153143
def to_json(opts = nil)
154-
status_hash.to_json(opts)
144+
safe_value.to_json(opts)
155145
end
156146

157147
def to_s
158148
to_json
159149
end
160150

151+
# This is the value with all non-UTF-8 characters removed, suitable for
152+
# printing or converting to JSON. It *should* only be possible to have
153+
# non-UTF-8 characters in stdout/stderr keys as they are not allowed from
154+
# tasks but we scrub the whole thing just in case.
155+
def safe_value
156+
Bolt::Util.walk_vals(value) do |val|
157+
if val.is_a?(String)
158+
# Replace invalid bytes with hex codes, ie. \xDE\xAD\xBE\xEF
159+
val.scrub { |c| c.bytes.map { |b| "\\x" + b.to_s(16).upcase }.join }
160+
else
161+
val
162+
end
163+
end
164+
end
165+
161166
def to_data
162-
Bolt::Util.walk_keys(status_hash, &:to_s)
167+
{
168+
"target" => @target.name,
169+
"action" => action,
170+
"object" => object,
171+
"status" => status,
172+
"value" => safe_value
173+
}
163174
end
164175

165176
def status

lib/bolt/result_set.rb

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,14 @@ def eql?(other)
9999
self.class == other.class && @results == other.results
100100
end
101101

102-
def to_a
103-
@results.map(&:status_hash)
104-
end
105-
106102
def to_json(opts = nil)
107-
@results.map(&:status_hash).to_json(opts)
103+
to_data.to_json(opts)
108104
end
109105

110106
def to_data
111107
@results.map(&:to_data)
112108
end
109+
alias to_a to_data
113110

114111
def to_s
115112
to_json

lib/bolt_server/transport_app.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ def initialize(config)
5757
end
5858

5959
def scrub_stack_trace(result)
60-
if result.dig(:value, '_error', 'details', 'stack_trace')
61-
result[:value]['_error']['details'].reject! { |k| k == 'stack_trace' }
60+
if result.dig('value', '_error', 'details', 'stack_trace')
61+
result['value']['_error']['details'].reject! { |k| k == 'stack_trace' }
6262
end
6363
result
6464
end
@@ -87,14 +87,14 @@ def validate_schema(schema, body)
8787
# If the `result_set` contains only one item, it will be returned
8888
# as a single result object. Set `aggregate` to treat it as a set
8989
# of results with length 1 instead.
90-
def result_set_to_status_hash(result_set, aggregate: false)
90+
def result_set_to_data(result_set, aggregate: false)
9191
scrubbed_results = result_set.map do |result|
92-
scrub_stack_trace(result.status_hash)
92+
scrub_stack_trace(result.to_data)
9393
end
9494

9595
if aggregate || scrubbed_results.length > 1
9696
# For actions that act on multiple targets, construct a status hash for the aggregate result
97-
all_succeeded = scrubbed_results.all? { |r| r[:status] == 'success' }
97+
all_succeeded = scrubbed_results.all? { |r| r['status'] == 'success' }
9898
{
9999
status: all_succeeded ? 'success' : 'failure',
100100
result: scrubbed_results
@@ -297,7 +297,7 @@ def make_ssh_target(target_hash)
297297
return [400, error.to_json] unless error.nil?
298298

299299
aggregate = body['target'].nil?
300-
[200, result_set_to_status_hash(result_set, aggregate: aggregate).to_json]
300+
[200, result_set_to_data(result_set, aggregate: aggregate).to_json]
301301
end
302302

303303
def make_winrm_target(target_hash)
@@ -337,7 +337,7 @@ def make_winrm_target(target_hash)
337337
return [400, error.to_json] if error
338338

339339
aggregate = body['target'].nil?
340-
[200, result_set_to_status_hash(result_set, aggregate: aggregate).to_json]
340+
[200, result_set_to_data(result_set, aggregate: aggregate).to_json]
341341
end
342342

343343
# Fetches the metadata for a single plan

spec/bolt/rerun_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232

3333
let(:failure_array) do
3434
result_set.map do |r|
35-
r = r.status_hash
36-
{ 'target' => r[:target], 'status' => r[:status] }
35+
r = r.to_data
36+
{ 'target' => r['target'], 'status' => r['status'] }
3737
end
3838
end
3939

spec/bolt_server/transport_app_spec.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def mock_plan_info(full_name)
7070
}
7171
end
7272
let(:action) { 'run_task' }
73-
let(:result) { double(Bolt::Result, status_hash: { status: 'test_status' }) }
73+
let(:result) { double(Bolt::Result, to_data: { 'status': 'test_status' }) }
7474

7575
before(:each) do
7676
allow_any_instance_of(BoltServer::TransportApp)
@@ -235,7 +235,7 @@ def mock_plan_info(full_name)
235235
} }
236236

237237
expect_any_instance_of(BoltServer::TransportApp)
238-
.to receive(:scrub_stack_trace).with(result.status_hash).and_return({})
238+
.to receive(:scrub_stack_trace).with(result.to_data).and_return({})
239239

240240
post(path, JSON.generate(body), 'CONTENT_TYPE' => 'text/json')
241241

@@ -255,7 +255,7 @@ def mock_plan_info(full_name)
255255
} }
256256

257257
expect_any_instance_of(BoltServer::TransportApp)
258-
.to receive(:scrub_stack_trace).with(result.status_hash).and_return({})
258+
.to receive(:scrub_stack_trace).with(result.to_data).and_return({})
259259

260260
post(path, JSON.generate(body), 'CONTENT_TYPE' => 'text/json')
261261

@@ -349,7 +349,7 @@ def mock_plan_info(full_name)
349349
} }
350350

351351
expect_any_instance_of(BoltServer::TransportApp)
352-
.to receive(:scrub_stack_trace).with(result.status_hash).and_return({})
352+
.to receive(:scrub_stack_trace).with(result.to_data).and_return({})
353353

354354
post(path, JSON.generate(body), 'CONTENT_TYPE' => 'text/json')
355355

0 commit comments

Comments
 (0)