Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support sensitive values in to_json_pretty #1418

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ This function will return list of nested Hash values and returns list of values
* [`stdlib::parsehocon`](#stdlib--parsehocon): This function accepts HOCON as a string and converts it into the correct
Puppet structure
* [`stdlib::powershell_escape`](#stdlib--powershell_escape): Escapes a string so that it can be safely used in a PowerShell command line.
* [`stdlib::rewrap_sensitive_data`](#stdlib--rewrap_sensitive_data): Unwraps any sensitives in data and returns a sensitive
* [`stdlib::seeded_rand`](#stdlib--seeded_rand): Generates a random whole number greater than or equal to 0 and less than max, using the value of seed for repeatable randomness.
* [`stdlib::seeded_rand_string`](#stdlib--seeded_rand_string): Generates a consistent random string of specific length based on provided seed.
* [`stdlib::sha256`](#stdlib--sha256): Run a SHA256 calculation against a given value.
Expand Down Expand Up @@ -3837,6 +3838,50 @@ Data type: `Any`

The string to escape

### <a name="stdlib--rewrap_sensitive_data"></a>`stdlib::rewrap_sensitive_data`

Type: Ruby 4.x API

It's not uncommon to have Sensitive strings as values within a hash or array.
Before passing the data to a type property or another function, it's useful
to be able to `unwrap` these values first. This function does this. If
sensitive data was included in the data, the whole result is then rewrapped
as Sensitive.

Optionally, this function can be passed a block. When a block is given, it will
be run with the unwrapped data, but before the final rewrapping. This is useful
to provide transparent rewrapping to other functions in stdlib especially.

This is analogous to the way `epp` transparently handles sensitive parameters.

#### `stdlib::rewrap_sensitive_data(Any $data, Optional[Callable[Any]] &$block)`

It's not uncommon to have Sensitive strings as values within a hash or array.
Before passing the data to a type property or another function, it's useful
to be able to `unwrap` these values first. This function does this. If
sensitive data was included in the data, the whole result is then rewrapped
as Sensitive.

Optionally, this function can be passed a block. When a block is given, it will
be run with the unwrapped data, but before the final rewrapping. This is useful
to provide transparent rewrapping to other functions in stdlib especially.

This is analogous to the way `epp` transparently handles sensitive parameters.

Returns: `Any` Returns the rewrapped data

##### `data`

Data type: `Any`

The data

##### `&block`

Data type: `Optional[Callable[Any]]`

A lambda that will be run after the data has been unwrapped, but before it is rewrapped, (if it contained sensitives)

### <a name="stdlib--seeded_rand"></a>`stdlib::seeded_rand`

Type: Ruby 4.x API
Expand Down
60 changes: 60 additions & 0 deletions lib/puppet/functions/stdlib/rewrap_sensitive_data.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

# @summary Unwraps any sensitives in data and returns a sensitive
#
# It's not uncommon to have Sensitive strings as values within a hash or array.
# Before passing the data to a type property or another function, it's useful
# to be able to `unwrap` these values first. This function does this. If
# sensitive data was included in the data, the whole result is then rewrapped
# as Sensitive.
#
# Optionally, this function can be passed a block. When a block is given, it will
# be run with the unwrapped data, but before the final rewrapping. This is useful
# to provide transparent rewrapping to other functions in stdlib especially.
#
# This is analogous to the way `epp` transparently handles sensitive parameters.
Puppet::Functions.create_function(:'stdlib::rewrap_sensitive_data') do
# @param data The data
# @param block A lambda that will be run after the data has been unwrapped, but before it is rewrapped, (if it contained sensitives)
# @return Returns the rewrapped data
dispatch :rewrap_sensitive_data do
param 'Any', :data
optional_block_param 'Callable[Any]', :block
return_type 'Any'
end

def rewrap_sensitive_data(data)
@contains_sensitive = false

unwrapped = deep_unwrap(data)

result = block_given? ? yield(unwrapped) : unwrapped
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the yielded unwrapped actually return a Sensitive. Or in other words, should it be yield(wrapped) instead?

Copy link
Collaborator Author

@alexjfisher alexjfisher Feb 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I think this is correct. The block, (if provided), needs to be handed the unwrapped data so that it can mutate it as desired (eg. serialising to yaml or json), before this function then wraps the result back up with sensitive.

I've added the first spec tests that hopefully illustrates this a bit better.


if @contains_sensitive
Puppet::Pops::Types::PSensitiveType::Sensitive.new(result)
else
result
end
end

def deep_unwrap(obj)
case obj
when Hash
obj.each_with_object({}) do |(key, value), result|
if key.is_a?(Puppet::Pops::Types::PSensitiveType::Sensitive)
# This situation is probably fairly unlikely in reality, but easy enough to support
@contains_sensitive = true
key = key.unwrap
end
result[key] = deep_unwrap(value)
end
when Array
obj.map { |element| deep_unwrap(element) }
when Puppet::Pops::Types::PSensitiveType::Sensitive
@contains_sensitive = true
deep_unwrap(obj.unwrap)
else
obj
end
end
end
8 changes: 5 additions & 3 deletions lib/puppet/functions/stdlib/to_json_pretty.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ def to_json_pretty(data, skip_undef = false, opts = nil)
end

data = data.compact if skip_undef && (data.is_a?(Array) || Hash)
# Call ::JSON to ensure it references the JSON library from Ruby's standard library
# instead of a random JSON namespace that might be in scope due to user code.
JSON.pretty_generate(data, opts) << "\n"
call_function('stdlib::rewrap_sensitive_data', data) do |unwrapped_data|
# Call ::JSON to ensure it references the JSON library from Ruby's standard library
# instead of a random JSON namespace that might be in scope due to user code.
::JSON.pretty_generate(unwrapped_data, opts) << "\n"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leading colons put back in to match existing comment. See #1307 (comment)

end
end
end
111 changes: 111 additions & 0 deletions spec/functions/rewrap_sensitive_data_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

require 'spec_helper'

describe 'stdlib::rewrap_sensitive_data' do
it { is_expected.not_to be_nil }

context 'when called with data containing no sensitive elements' do
it { is_expected.to run.with_params({}).and_return({}) }
it { is_expected.to run.with_params([]).and_return([]) }
it { is_expected.to run.with_params('a_string').and_return('a_string') }
it { is_expected.to run.with_params(42).and_return(42) }
it { is_expected.to run.with_params(true).and_return(true) }
it { is_expected.to run.with_params(false).and_return(false) }

it { is_expected.to run.with_params({ 'foo' => 'bar' }).and_return({ 'foo' => 'bar' }) }
end

context 'when called with a hash containing a sensitive string' do
it 'unwraps the sensitive string and returns a sensitive hash' do
is_expected.to run.with_params(
{
'username' => 'my_user',
'password' => sensitive('hunter2')
},
).and_return(sensitive(
{
'username' => 'my_user',
'password' => 'hunter2'
},
))
end
end

context 'when called with data containing lots of sensitive elements (including nested in arrays, and sensitive hashes etc)' do
it 'recursively unwraps everything and marks the whole result as sensitive' do
is_expected.to run.with_params(
{
'a' => sensitive('bar'),
'b' => [
1,
2,
:undef,
true,
false,
{
'password' => sensitive('secret'),
'weird_example' => sensitive({ 'foo' => sensitive(42) }) # A sensitive hash containing a sensitive Int as the value to a hash contained in an array which is the value of a hash key...
},
],
'c' => :undef,
'd' => [],
'e' => true,
'f' => false,
},
).and_return(sensitive(
{
'a' => 'bar',
'b' => [
1,
2,
:undef,
true,
false,
{
'password' => 'secret',
'weird_example' => { 'foo' => 42 }
},
],
'c' => :undef,
'd' => [],
'e' => true,
'f' => false,
},
))
end
end

context 'when a hash _key_ is sensitive' do
it 'unwraps the key' do
is_expected.to run.with_params(
{
sensitive('key') => 'value',
},
).and_return(sensitive(
{
'key' => 'value',
},
))
end
end

context 'when called with a block' do
context 'that upcases hash values' do
it do
is_expected.to run
.with_params({ 'secret' => sensitive('hunter2') })
.with_lambda { |data| data.transform_values { |value| value.upcase } }
.and_return(sensitive({ 'secret' => 'HUNTER2' }))
end
end
context 'that converts data to yaml' do
it do
is_expected.to run
.with_params({ 'secret' => sensitive('hunter2') })
.with_lambda { |data| data.to_yaml }
.and_return(sensitive("---\nsecret: hunter2\n"))
end
end
end
end
4 changes: 4 additions & 0 deletions spec/functions/to_json_pretty_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@
pending('Current implementation only elides nil values for hashes of depth=1')
expect(subject).to run.with_params({ 'omg' => { 'lol' => nil }, 'what' => nil }, true).and_return("{\n}\n")
}

context 'with data containing sensitive' do
it { is_expected.to run.with_params('key' => sensitive('value')).and_return(sensitive("{\n \"key\": \"value\"\n}\n")) }
end
end
Loading