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

UI: Obscure values for nested KV v2 secret #24530

Merged
merged 9 commits into from
Dec 14, 2023
3 changes: 3 additions & 0 deletions changelog/24530.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
ui: obscure JSON values when KV v2 secret has nested objects
```
10 changes: 9 additions & 1 deletion ui/lib/core/addon/components/json-editor.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
data-test-restore-example
/>
{{/if}}
{{#if (and @obscure @readOnly)}}
{{! For safety we only use obscured values in readonly mode }}
<div>
<Toggle @name="revealValues" @checked={{this.revealValues}} @onChange={{fn (mut this.revealValues)}}>
<span class="has-text-grey">Reveal values</span>
</Toggle>
</div>
{{/if}}
<div class="toolbar-separator"></div>
<Hds::Copy::Button
@container={{@container}}
Expand All @@ -40,7 +48,7 @@
{{/if}}
<div
{{code-mirror
content=(or @value @example)
content=(if this.showObfuscatedData this.obfuscatedData (or @value @example))
extraKeys=@extraKeys
gutters=@gutters
lineNumbers=(if @readOnly false true)
Expand Down
11 changes: 11 additions & 0 deletions ui/lib/core/addon/components/json-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { stringify } from 'core/helpers/stringify';
import { obfuscateData } from 'core/utils/advanced-secret';

/**
* @module JsonEditor
Expand Down Expand Up @@ -34,10 +37,18 @@ import { action } from '@ember/object';
*/

export default class JsonEditorComponent extends Component {
@tracked revealValues = false;
get getShowToolbar() {
return this.args.showToolbar === false ? false : true;
}

get showObfuscatedData() {
return this.args.readOnly && this.args.obscure && !this.revealValues;
}
get obfuscatedData() {
return stringify([obfuscateData(JSON.parse(this.args.value))], {});
}

@action
onSetup(editor) {
// store reference to codemirror editor so that it can be passed to valueUpdated when restoring example
Expand Down
20 changes: 20 additions & 0 deletions ui/lib/core/addon/utils/advanced-secret.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,23 @@ export function isAdvancedSecret(value) {
return false;
}
}

/**
* Method to obfuscate all values in a map, including nested values and arrays
* @param obj object
* @returns object
*/
export function obfuscateData(obj) {
if (typeof obj !== 'object' || Array.isArray(obj)) return obj;
const newObj = {};
for (const key of Object.keys(obj)) {
if (Array.isArray(obj[key])) {
newObj[key] = obj[key].map(() => '********');
} else if (typeof obj[key] === 'object') {
newObj[key] = obfuscateData(obj[key]);
} else {
newObj[key] = '********';
}
}
return newObj;
}
1 change: 1 addition & 0 deletions ui/lib/kv/addon/components/kv-data-fields.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<JsonEditor
@title="{{if (eq @type 'create') 'Secret' 'Version'}} data"
@value={{this.codeMirrorString}}
@obscure={{@obscureJson}}
@valueUpdated={{this.handleJson}}
@readOnly={{eq @type "details"}}
/>
Expand Down
1 change: 1 addition & 0 deletions ui/lib/kv/addon/components/kv-data-fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { stringify } from 'core/helpers/stringify';
* @param {object} [modelValidations] - object of errors. If attr.name is in object and has error message display in AlertInline.
* @param {callback} [pathValidations] - callback function fired for the path input on key up
* @param {boolean} [type=null] - can be edit, create, or details. Used to change text for some form labels
* @param {boolean} [obscureJson=false] - used to obfuscate json values in JsonEditor
*/

export default class KvDataFields extends Component {
Expand Down
1 change: 1 addition & 0 deletions ui/lib/kv/addon/components/page/secret/details.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
{{else}}
<KvDataFields
@showJson={{or this.showJsonView this.secretDataIsAdvanced}}
@obscureJson={{this.secretDataIsAdvanced}}
@secret={{@secret}}
@modelValidations={{this.modelValidations}}
@type="details"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,17 +272,35 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
});

test('advanced secret values default to JSON display', async function (assert) {
const obscuredData = `{
"foo3": {
"name": "********"
}
}`;
await visit(`/vault/secrets/${this.backend}/kv/create`);
await fillIn(FORM.inputByAttr('path'), 'complex');

await click(FORM.toggleJson);
assert.strictEqual(codemirror().getValue(), '{ "": "" }');
codemirror().setValue('{ "foo3": { "name": "bar3" } }');
await click(FORM.saveBtn);
// Future: test that json is automatic on details too

// Details view
assert.dom(FORM.toggleJson).isDisabled();
assert.dom(FORM.toggleJson).isChecked();
assert.strictEqual(
codemirror().getValue(),
obscuredData,
'Value is obscured by default on details view when advanced'
);
await click('[data-test-toggle-input="revealValues"]');
assert.false(codemirror().getValue().includes('*'), 'Value unobscured after toggle');

// New version view
await click(PAGE.detail.createNewVersion);
assert.dom(FORM.toggleJson).isDisabled();
assert.dom(FORM.toggleJson).isChecked();
assert.false(codemirror().getValue().includes('*'), 'Values are not obscured on edit view');
});
test('does not register as advanced when value includes {', async function (assert) {
await visit(`/vault/secrets/${this.backend}/kv/create`);
Expand Down
21 changes: 21 additions & 0 deletions ui/tests/integration/components/json-editor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,25 @@ module('Integration | Component | json-editor', function (hooks) {
assert.dom('.CodeMirror-code').hasText(`1${this.example}`, 'Example is restored');
assert.strictEqual(this.value, null, 'Value is cleared on restore example');
});

test('obscure option works correctly', async function (assert) {
this.set('readOnly', true);
await render(hbs`<JsonEditor
@value={{this.json_blob}}
@obscure={{true}}
@readOnly={{this.readOnly}}
@valueUpdated={{this.valueUpdated}}
@onFocusOut={{this.onFocusOut}}
/>`);
assert.dom('.CodeMirror-code').hasText(`{ "test": "********"}`, 'shows data with obscured values');
assert.dom('[data-test-toggle-input="revealValues"]').isNotChecked('reveal values toggle is unchecked');
await click('[data-test-toggle-input="revealValues"]');
assert.dom('.CodeMirror-code').hasText(JSON_BLOB, 'shows data with real values');
assert.dom('[data-test-toggle-input="revealValues"]').isChecked('reveal values toggle is checked');
// turn obscure back on to ensure readonly overrides reveal setting
await click('[data-test-toggle-input="revealValues"]');
this.set('readOnly', false);
assert.dom('[data-test-toggle-input="revealValues"]').doesNotExist('reveal values toggle is hidden');
assert.dom('.CodeMirror-code').hasText(JSON_BLOB, 'shows data with real values on edit mode');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
);
assert.dom(PAGE.infoRowValue('foo')).doesNotExist('does not render rows of secret data');
assert.dom(FORM.toggleJson).isDisabled();
assert.dom('[data-test-component="code-mirror-modifier"]').includesText(`{ "foo": { "bar": "baz" }}`);
assert.dom('[data-test-component="code-mirror-modifier"]').exists('shows json editor');
});

test('it renders deleted empty state', async function (assert) {
Expand Down
124 changes: 100 additions & 24 deletions ui/tests/unit/utils/advanced-secret-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,114 @@
* SPDX-License-Identifier: BUSL-1.1
*/

import { isAdvancedSecret } from 'core/utils/advanced-secret';
import { isAdvancedSecret, obfuscateData } from 'core/utils/advanced-secret';
import { module, test } from 'qunit';

module('Unit | Utility | advanced-secret', function () {
test('it returns false for non-valid JSON', function (assert) {
assert.expect(5);
let result;
['some-string', 'character{string', '{value}', '[blah]', 'multi\nline\nstring'].forEach((value) => {
result = isAdvancedSecret('some-string');
assert.false(result, `returns false for ${value}`);
module('isAdvancedSecret', function () {
test('it returns false for non-valid JSON', function (assert) {
assert.expect(5);
let result;
['some-string', 'character{string', '{value}', '[blah]', 'multi\nline\nstring'].forEach((value) => {
result = isAdvancedSecret('some-string');
assert.false(result, `returns false for ${value}`);
});
});

test('it returns false for single-level objects', function (assert) {
assert.expect(3);
let result;
[{ single: 'one' }, { first: '1', two: 'three' }, ['my', 'array']].forEach((value) => {
result = isAdvancedSecret(JSON.stringify(value));
assert.false(result, `returns false for object ${JSON.stringify(value)}`);
});
});
});

test('it returns false for single-level objects', function (assert) {
assert.expect(3);
let result;
[{ single: 'one' }, { first: '1', two: 'three' }, ['my', 'array']].forEach((value) => {
result = isAdvancedSecret(JSON.stringify(value));
assert.false(result, `returns false for object ${JSON.stringify(value)}`);
test('it returns true for any nested object', function (assert) {
assert.expect(3);
let result;
[
{ single: { one: 'uno' } },
{ first: ['this', 'counts\ntoo'] },
{ deeply: { nested: { item: 1 } } },
].forEach((value) => {
result = isAdvancedSecret(JSON.stringify(value));
assert.true(result, `returns true for object ${JSON.stringify(value)}`);
});
});
});
module('obfuscateData', function () {
test('it obfuscates values of an object', function (assert) {
assert.expect(4);
[
{
name: 'flat map',
data: {
first: 'one',
second: 'two',
third: 'three',
},
obscured: {
first: '********',
second: '********',
third: '********',
},
},
{
name: 'nested map',
data: {
first: 'one',
second: {
third: 'two',
},
},
obscured: {
first: '********',
second: {
third: '********',
},
},
},
{
name: 'numbers and arrays',
data: {
first: 1,
list: ['one', 'two'],
second: {
third: ['one', 'two'],
number: 5,
},
},
obscured: {
first: '********',
list: ['********', '********'],
second: {
third: ['********', '********'],
number: '********',
},
},
},
{
name: 'object arrays',
data: {
list: [{ one: 'one' }, { two: 'two' }],
},
obscured: {
list: ['********', '********'],
},
},
].forEach((test) => {
const result = obfuscateData(test.data);
assert.deepEqual(result, test.obscured, `obfuscates values of ${test.name}`);
});
});

test('it returns true for any nested object', function (assert) {
assert.expect(3);
let result;
[
{ single: { one: 'uno' } },
{ first: ['this', 'counts\ntoo'] },
{ deeply: { nested: { item: 1 } } },
].forEach((value) => {
result = isAdvancedSecret(JSON.stringify(value));
assert.true(result, `returns true for object ${JSON.stringify(value)}`);
test('it does not obfuscate non-object values', function (assert) {
assert.expect(3);
['some-string', 5, ['my', 'array']].forEach((test) => {
const result = obfuscateData(test);
assert.deepEqual(result, test, `does not obfuscate value ${JSON.stringify(test)}`);
});
});
});
});
Loading