Skip to content

Commit 3b1d6e3

Browse files
authored
feat(redact): add a dictionary of secrets to redact MCP-29 (#569)
* 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). * chore: add support for arbitrarily deep object redacting * chore: refactor to reuse the iteration, instead of iterating twice * chore: fix linter warnings * chore: fix an issue when the constructor is not a function We don't want to return a truthy value if the ctor is a non function value (like an object). Plain objects either have a plain object as a prototype or a null prototype
1 parent 913ca6a commit 3b1d6e3

File tree

8 files changed

+260
-25
lines changed

8 files changed

+260
-25
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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,25 @@ 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 in strings', function () {
29+
const res = redact('foo@bar', [{ value: 'foo@bar', kind: 'password' }]);
30+
expect(res).to.equal('<password>');
31+
});
32+
33+
it('should redact provided secrets in objects', function () {
34+
const res = redact({ key: 'foo@bar' }, [
35+
{ value: 'foo@bar', kind: 'password' },
36+
]);
37+
expect(res).to.deep.equal({ key: '<password>' });
38+
});
39+
40+
it('should redact provided secrets in arrays', function () {
41+
const res = redact(['foo@bar'], [{ value: 'foo@bar', kind: 'password' }]);
42+
expect(res).to.deep.equal(['<password>']);
43+
});
44+
});
45+
2746
describe('Types', function () {
2847
it('should work with string types', function () {
2948
const res = redact('[email protected]');

packages/mongodb-redact/src/index.ts

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,42 @@
11
import { regexes } from './regexes';
2+
import { isPlainObject } from './utils';
3+
import { redactSecretsOnString } 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;
11-
}
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-
}
22-
23-
export function redact<T>(message: T): T {
6+
export function redact<T>(
7+
message: T,
8+
secrets: Secret[] | undefined = undefined,
9+
): T {
2410
if (isPlainObject(message)) {
2511
// recursively walk through all values of an object
26-
return Object.fromEntries(
27-
Object.entries(message).map(([key, value]) => [key, redact(value)]),
12+
const newMessage = Object.fromEntries(
13+
Object.entries(message).map(([key, value]) => [
14+
key,
15+
redact(value, secrets),
16+
]),
2817
) as T;
18+
19+
// make sure we inherit the prototype so we don't add new behaviour to the object
20+
// nobody is expecting
21+
return Object.setPrototypeOf(
22+
newMessage,
23+
Object.getPrototypeOf(message) as object | null,
24+
);
2925
}
26+
3027
if (Array.isArray(message)) {
3128
// walk through array and redact each value
32-
return message.map(redact) as T;
29+
return message.map((msg) => redact(msg, secrets)) as T;
3330
}
3431
if (typeof message !== 'string') {
3532
// all non-string types can be safely returned
3633
return message;
3734
}
35+
36+
if (secrets) {
37+
message = redactSecretsOnString(message, secrets);
38+
}
39+
3840
// apply all available regexes to the string
3941
for (const [regex, replacement] of regexes) {
4042
// The type here isn't completely accurate in case `T` is a specific string template
@@ -45,3 +47,4 @@ export function redact<T>(message: T): T {
4547
}
4648

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

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 false;
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)