Skip to content
Merged
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
6 changes: 4 additions & 2 deletions src/commands/configure/environments/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const BoxCommand = require('../../../box-command');
const { Flags } = require('@oclif/core');
const _ = require('lodash');
const utilities = require('../../../util');

class EnvironmentsGetCommand extends BoxCommand {
async run() {
Expand All @@ -22,7 +23,7 @@ class EnvironmentsGetCommand extends BoxCommand {
if (_.isEmpty(environment)) {
this.error('No environment(s) exists');
} else {
await this.output(environment);
await this.output(utilities.maskObjectValuesByKey(environment));
}
}
}
Expand All @@ -31,7 +32,8 @@ class EnvironmentsGetCommand extends BoxCommand {
EnvironmentsGetCommand.noClient = true;
EnvironmentsGetCommand.aliases = ['configure:environments:list'];

EnvironmentsGetCommand.description = 'Get a Box environment';
EnvironmentsGetCommand.description =
'Get a Box environment or list all configured Box environments.\nclientSecret values are masked in CLI output. To view full secrets, access secure storage directly (for example, macOS Keychain, Windows Credential Manager, or a supported Linux equivalent).';

EnvironmentsGetCommand.flags = {
...BoxCommand.minFlags,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/configure/environments/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class EnvironmentsUpdateCommand extends BoxCommand {
}

await this.updateEnvironments(environmentsObject);
await this.output(environment);
await this.output(utilities.maskObjectValuesByKey(environment));
}
}

Expand Down
41 changes: 41 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const PATH_ESCAPES = Object.freeze({
'/': '~1',
'~': '~0',
});
const DEFAULT_SECRET_KEY = 'clientSecret';
const DEFAULT_VISIBLE_SECRET_CHARS = 3;

/**
* Unescape a string that no longer needs to be escaped with slashes,
Expand Down Expand Up @@ -299,6 +301,43 @@ async function unlinkAsync(path) {
});
}

function maskSecret(secret, visibleChars = DEFAULT_VISIBLE_SECRET_CHARS) {
const normalizedVisibleChars =
_.isInteger(visibleChars) && visibleChars >= 0
? visibleChars
: DEFAULT_VISIBLE_SECRET_CHARS;

if (!_.isString(secret) || secret.length <= normalizedVisibleChars) {
return '*'.repeat(normalizedVisibleChars);
}

return `${'*'.repeat(secret.length - normalizedVisibleChars)}${secret.slice(-normalizedVisibleChars)}`;
}

function maskObjectValuesByKey(
value,
keyToMask = DEFAULT_SECRET_KEY,
visibleChars = DEFAULT_VISIBLE_SECRET_CHARS
) {
if (_.isArray(value)) {
return value.map((item) =>
maskObjectValuesByKey(item, keyToMask, visibleChars)
);
}

if (_.isPlainObject(value)) {
return _.mapValues(value, (objectValue, key) => {
if (key === keyToMask && !_.isNil(objectValue)) {
return maskSecret(objectValue, visibleChars);
}

return maskObjectValuesByKey(objectValue, keyToMask, visibleChars);
});
}

return value;
}

module.exports = {
/**
* Validates the a configuration object has all required properties
Expand Down Expand Up @@ -388,6 +427,8 @@ module.exports = {
parseMetadataOp(value) {
return parseMetadataString(value);
},
maskSecret,
maskObjectValuesByKey,
parseStringToObject,
checkDir,
readFileAsync,
Expand Down
79 changes: 79 additions & 0 deletions test/commands/configure-environments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use strict';

const { test } = require('@oclif/test');
const { assert } = require('chai');
const sinon = require('sinon');
const BoxCommand = require('../../src/box-command');

describe('Configure environments masking', function () {
let sandbox;
let getEnvironmentsStub;
let updateEnvironmentsStub;

const RAW_SECRET = 'abcdefghijklmnopqrstuvwxyz123456';
const MASKED_SECRET = `${'*'.repeat(RAW_SECRET.length - 3)}${RAW_SECRET.slice(-3)}`;

beforeEach(function () {
sandbox = sinon.createSandbox();
getEnvironmentsStub = sandbox.stub(BoxCommand.prototype, 'getEnvironments');
updateEnvironmentsStub = sandbox.stub(
BoxCommand.prototype,
'updateEnvironments'
);
updateEnvironmentsStub.resolves();
});

afterEach(function () {
sandbox.restore();
});

it('configure:environments:get should not print raw client secrets', function () {
getEnvironmentsStub.resolves({
default: 'dev',
environments: {
dev: {
name: 'dev',
clientSecret: RAW_SECRET,
},
},
});

return test
.stub(BoxCommand.prototype, 'getEnvironments', getEnvironmentsStub)
.stdout()
.command(['configure:environments:get'])
.it('masks clientSecret in output', (ctx) => {
assert.notInclude(ctx.stdout, RAW_SECRET);
assert.include(ctx.stdout, MASKED_SECRET);
});
});

it(
'configure:environments:update should not print raw client secrets',
function () {
getEnvironmentsStub.resolves({
default: 'dev',
environments: {
dev: {
name: 'dev',
clientSecret: RAW_SECRET,
},
},
});

return test
.stub(BoxCommand.prototype, 'getEnvironments', getEnvironmentsStub)
.stub(
BoxCommand.prototype,
'updateEnvironments',
updateEnvironmentsStub
)
.stdout()
.command(['configure:environments:update'])
.it('masks clientSecret in output', (ctx) => {
assert.notInclude(ctx.stdout, RAW_SECRET);
assert.include(ctx.stdout, MASKED_SECRET);
});
}
);
});
69 changes: 69 additions & 0 deletions test/util.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,75 @@ describe('Utilities', function () {
);
});

describe('secret masking helpers', function () {
it('maskSecret() should preserve length and reveal only last 3 chars by default', function () {
const secret = 'abcdefghijklmnopqrstuvwxyz123456';
const masked = cliUtilities.maskSecret(secret);

assert.strictEqual(masked.length, secret.length);
assert.strictEqual(masked.slice(-3), '456');
assert.strictEqual(
masked,
`${'*'.repeat(secret.length - 3)}${secret.slice(-3)}`
);
});

it('maskSecret() should fallback to default visible chars for invalid values', function () {
const secret = 'abcdef';
const masked = cliUtilities.maskSecret(secret, -1);

assert.strictEqual(masked, '***def');
});

it('maskObjectValuesByKey() should mask clientSecret recursively by default', function () {
const input = {
name: 'dev',
clientSecret: '1234567890',
nested: {
clientSecret: 'abcdefghij',
},
items: [
{
clientSecret: 'qwertyuiop',
},
{
other: 'value',
},
],
};

const masked = cliUtilities.maskObjectValuesByKey(input);

assert.strictEqual(input.clientSecret, '1234567890');
assert.strictEqual(masked.clientSecret, '*******890');
assert.strictEqual(input.nested.clientSecret, 'abcdefghij');
assert.strictEqual(masked.nested.clientSecret, '*******hij');
assert.strictEqual(input.items[0].clientSecret, 'qwertyuiop');
assert.strictEqual(masked.items[0].clientSecret, '*******iop');
assert.strictEqual(masked.items[1].other, 'value');

});

it('maskObjectValuesByKey() should support custom key and visible chars', function () {
const input = {
token: 'abcd1234',
nested: {
token: 'wxyz9876',
},
clientSecret: 'dont-mask-default-key-if-custom-is-used',
};

const masked = cliUtilities.maskObjectValuesByKey(input, 'token', 2);

assert.strictEqual(input.token, 'abcd1234');
assert.strictEqual(masked.token, '******34');
assert.strictEqual(input.nested.token, 'wxyz9876');
assert.strictEqual(masked.nested.token, '******76');
assert.strictEqual(input.clientSecret, 'dont-mask-default-key-if-custom-is-used');
assert.strictEqual(masked.clientSecret,'dont-mask-default-key-if-custom-is-used');
});
});

describe('checkDir()', function () {
it('should create directory if create flag is true', async function () {
const destination = `${process.cwd()}/temp`;
Expand Down
Loading