Skip to content

Commit cb5a83b

Browse files
committed
feat(redact): add a dictionary of secrets to redact
With this feature we support redacting known secrets from a dictionary. This is value for environments like the MCP Server where we create new users (user/password) or when we can infer secrets from CLI arguments (--user, --pasword).
1 parent 368c0be commit cb5a83b

File tree

8 files changed

+252
-21
lines changed

8 files changed

+252
-21
lines changed

package-lock.json

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/mongodb-redact/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@mongodb-js/tsconfig-devtools": "^1.0.3",
5959
"@types/chai": "^4.2.21",
6060
"@types/mocha": "^9.1.1",
61+
"@types/regexp.escape": "^2.0.0",
6162
"@types/sinon-chai": "^3.2.5",
6263
"chai": "^4.5.0",
6364
"depcheck": "^1.4.7",
@@ -68,5 +69,8 @@
6869
"prettier": "^3.5.3",
6970
"sinon": "^9.2.3",
7071
"typescript": "^5.0.4"
72+
},
73+
"dependencies": {
74+
"regexp.escape": "^2.0.1"
7175
}
7276
}

packages/mongodb-redact/src/index.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ const BIN_DATA = `db = db.getSiblingDB("__realm_sync")
2424
db.history.updateOne({"_id": ObjectId("63ed1d522d8573fa5c203660")}, {$set:{changeset:BinData(5, "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAADMElEQVR4nOzVwQnAIBQFQYXff81RUkQCOyDj1YOPnbXWPmeTRef+/3O/OyBjzh3CD95BfqICMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMO0TAAD//2Anhf4QtqobAAAAAElFTkSuQmCC")}})`;
2525

2626
describe('mongodb-redact', function () {
27+
describe('Secrets', function () {
28+
it('should redact provided secrets', function () {
29+
const res = redact('foo@bar', [{ value: 'foo@bar', kind: 'password' }]);
30+
expect(res).to.equal('<password>');
31+
});
32+
});
33+
2734
describe('Types', function () {
2835
it('should work with string types', function () {
2936
const res = redact('[email protected]');

packages/mongodb-redact/src/index.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
11
import { regexes } from './regexes';
2+
import { isPlainObject } from './utils';
3+
import { redactSecrets } from './secrets';
4+
import type { Secret } from './secrets';
25

3-
const plainObjectTag = Object.prototype.toString.call({});
4-
function isPlainObject(val: unknown): val is object {
5-
if (
6-
typeof val !== 'object' ||
7-
!val ||
8-
Object.prototype.toString.call(val) !== plainObjectTag
9-
) {
10-
return false;
6+
export function redact<T>(
7+
message: T,
8+
secrets: Secret[] | undefined = undefined,
9+
): T {
10+
if (secrets) {
11+
message = redactSecrets(message, secrets);
1112
}
12-
const proto = Object.getPrototypeOf(val);
13-
if (proto === null) return true;
14-
if (!Object.prototype.hasOwnProperty.call(proto, 'constructor')) return false;
15-
const ctor = proto.constructor;
16-
if (typeof ctor !== 'function') return ctor;
17-
// `ctor === Object` but this works across contexts
18-
// (Object is special because Object.__proto__.__proto__ === Object.prototype),
19-
const ctorPrototype = Object.getPrototypeOf(ctor);
20-
return Object.getPrototypeOf(ctorPrototype) === ctor.prototype;
21-
}
2213

23-
export function redact<T>(message: T): T {
2414
if (isPlainObject(message)) {
2515
// recursively walk through all values of an object
2616
return Object.fromEntries(
@@ -29,7 +19,7 @@ export function redact<T>(message: T): T {
2919
}
3020
if (Array.isArray(message)) {
3121
// walk through array and redact each value
32-
return message.map(redact) as T;
22+
return message.map((msg) => redact(msg)) as T;
3323
}
3424
if (typeof message !== 'string') {
3525
// all non-string types can be safely returned
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { expect } from 'chai';
2+
import { SECRET_KIND, redactSecrets } from './secrets';
3+
import type { SecretKind } from './secrets';
4+
5+
describe('secret redaction on a string', function () {
6+
for (const kind of SECRET_KIND) {
7+
it(`redacts content of kind '${kind}'`, function () {
8+
const secret = '123456';
9+
const content = '123456';
10+
11+
const redacted = redactSecrets(content, [{ value: secret, kind: kind }]);
12+
13+
expect(redacted).equal(`<${kind}>`);
14+
});
15+
}
16+
17+
for (const invalidValue of [null, undefined, false, 0]) {
18+
it(`returns itself on an invalid value like ${String(invalidValue)}`, function () {
19+
expect(redactSecrets(invalidValue as any, [])).equal(invalidValue);
20+
});
21+
}
22+
23+
it('rejects unknown types', function () {
24+
expect(() =>
25+
redactSecrets('some content to redact', [
26+
{
27+
value: 'some',
28+
kind: 'invalid' as SecretKind,
29+
},
30+
]),
31+
).throw();
32+
});
33+
34+
it('redacts secrets in between word boundaries', function () {
35+
const secret = '123456';
36+
const content = '.123456.';
37+
38+
const redacted = redactSecrets(content, [
39+
{ value: secret, kind: 'password' },
40+
]);
41+
42+
expect(redacted).equal('.<password>.');
43+
});
44+
45+
it('does not redact content that seems a secret inside another word', function () {
46+
const secret = '123456';
47+
const content = 'abc123456def';
48+
49+
const redacted = redactSecrets(content, [
50+
{ value: secret, kind: 'password' },
51+
]);
52+
53+
expect(redacted).equal('abc123456def');
54+
});
55+
56+
it('escapes values so using it in regexes is safe', function () {
57+
const secret = '.+';
58+
const content = '.abcdef.';
59+
60+
const redacted = redactSecrets(content, [
61+
{ value: secret, kind: 'password' },
62+
]);
63+
64+
expect(redacted).equal('.abcdef.');
65+
});
66+
67+
it('redacts on arrays', function () {
68+
const secret = 'abc';
69+
const content = ['abc', 'cbd'];
70+
71+
const redacted = redactSecrets(content, [
72+
{ value: secret, kind: 'password' },
73+
]);
74+
75+
expect(redacted).deep.equal(['<password>', 'cbd']);
76+
});
77+
78+
it('redacts on objects', function () {
79+
const pwdSecret = '123456';
80+
const usrSecret = 'admin';
81+
82+
const content = { pwd: pwdSecret, usr: usrSecret };
83+
84+
const redacted = redactSecrets(content, [
85+
{ value: pwdSecret, kind: 'password' },
86+
{ value: usrSecret, kind: 'user' },
87+
]);
88+
89+
expect(redacted).deep.equal({
90+
pwd: '<password>',
91+
usr: '<user>',
92+
});
93+
});
94+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { isPlainObject } from './utils';
2+
3+
// RegExp.escape is not widely available yet, so we are going to use regexp.escape
4+
// as it's extremely simple and at some point we can get rid of it easily.
5+
import escape from 'regexp.escape';
6+
7+
export const SECRET_KIND = [
8+
'base64',
9+
'private key',
10+
'user',
11+
'password',
12+
'email',
13+
'ip',
14+
'url',
15+
'mongodb uri',
16+
] as const;
17+
18+
export type SecretKind = (typeof SECRET_KIND)[number];
19+
20+
export type Secret = {
21+
readonly value: string;
22+
readonly kind: SecretKind;
23+
};
24+
25+
function redactSecretsOnString<T extends string>(
26+
content: T,
27+
secrets: Secret[],
28+
): T {
29+
let result = content;
30+
for (const { value, kind } of secrets) {
31+
if (!SECRET_KIND.includes(kind)) {
32+
throw new Error(
33+
`Unknown secret kind ${kind}. Valid types: ${SECRET_KIND.join(', ')}`,
34+
);
35+
}
36+
37+
const regex = new RegExp(`\\b${escape(value)}\\b`);
38+
result = result.replace(regex, `<${kind}>`) as T;
39+
}
40+
41+
return result;
42+
}
43+
44+
export function redactSecrets<T>(message: T, secrets: Secret[]): T {
45+
if (isPlainObject(message)) {
46+
return Object.fromEntries(
47+
Object.entries(message).map(([key, value]) => [
48+
key,
49+
redactSecrets(value, secrets),
50+
]),
51+
) as T;
52+
}
53+
54+
if (Array.isArray(message)) {
55+
return message.map((e) => redactSecrets(e, secrets)) as T;
56+
}
57+
58+
if (typeof message === 'string') {
59+
return redactSecretsOnString(message, secrets);
60+
}
61+
62+
return message;
63+
}

packages/mongodb-redact/src/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const plainObjectTag = Object.prototype.toString.call({});
2+
3+
export function isPlainObject(val: unknown): val is object {
4+
if (
5+
typeof val !== 'object' ||
6+
!val ||
7+
Object.prototype.toString.call(val) !== plainObjectTag
8+
) {
9+
return false;
10+
}
11+
const proto = Object.getPrototypeOf(val);
12+
if (proto === null) return true;
13+
if (!Object.prototype.hasOwnProperty.call(proto, 'constructor')) return false;
14+
const ctor = proto.constructor;
15+
if (typeof ctor !== 'function') return ctor;
16+
// `ctor === Object` but this works across contexts
17+
// (Object is special because Object.__proto__.__proto__ === Object.prototype),
18+
const ctorPrototype = Object.getPrototypeOf(ctor);
19+
return Object.getPrototypeOf(ctorPrototype) === ctor.prototype;
20+
}

packages/mongodb-redact/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"compilerOptions": {
44
"outDir": "dist",
55
"allowJs": true,
6-
"strict": true
6+
"strict": true,
7+
"esModuleInterop": true
78
},
89
"include": ["src/**/*"],
910
"exclude": ["./src/**/*.spec.*"]

0 commit comments

Comments
 (0)