diff --git a/package-lock.json b/package-lock.json index 3426513e..172ce5cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30947,6 +30947,7 @@ "version": "1.2.2", "license": "Apache-2.0", "dependencies": { + "mongodb-connection-string-url": "^3.0.1 || ^7.0.0", "regexp.escape": "^2.0.1" }, "devDependencies": { @@ -51028,6 +51029,7 @@ "eslint": "^7.25.0", "gen-esm-wrapper": "^1.1.3", "mocha": "^8.4.0", + "mongodb-connection-string-url": "^3.0.1 || ^7.0.0", "nyc": "^15.1.0", "prettier": "^3.5.3", "regexp.escape": "^2.0.1", diff --git a/packages/devtools-connect/src/log-hook.ts b/packages/devtools-connect/src/log-hook.ts index 38f64cbf..753c61ce 100644 --- a/packages/devtools-connect/src/log-hook.ts +++ b/packages/devtools-connect/src/log-hook.ts @@ -28,7 +28,7 @@ export function hookLogger( emitter: ConnectLogEmitter, log: MongoLogWriter, contextPrefix: string, - redactURICredentials: (uri: string) => string, + redactConnectionString: (uri: string) => string, ): void { oidcHookLogger(emitter, log, contextPrefix); proxyHookLogger(emitter, log, contextPrefix); @@ -44,7 +44,7 @@ export function hookLogger( 'Initiating connection attempt', { ...ev, - uri: redactURICredentials(ev.uri), + uri: redactConnectionString(ev.uri), }, ); }, @@ -119,7 +119,7 @@ export function hookLogger( `${contextPrefix}-connect`, 'Resolving SRV record failed', { - from: redactURICredentials(ev.from), + from: redactConnectionString(ev.from), error: ev.error?.message, duringLoad: ev.duringLoad, resolutionDetails: ev.resolutionDetails, @@ -138,8 +138,8 @@ export function hookLogger( `${contextPrefix}-connect`, 'Resolving SRV record succeeded', { - from: redactURICredentials(ev.from), - to: redactURICredentials(ev.to), + from: redactConnectionString(ev.from), + to: redactConnectionString(ev.to), resolutionDetails: ev.resolutionDetails, durationMs: ev.durationMs, }, diff --git a/packages/mongodb-redact/package.json b/packages/mongodb-redact/package.json index 5f6246d4..eb8643de 100644 --- a/packages/mongodb-redact/package.json +++ b/packages/mongodb-redact/package.json @@ -71,6 +71,7 @@ "typescript": "^5.0.4" }, "dependencies": { - "regexp.escape": "^2.0.1" + "regexp.escape": "^2.0.1", + "mongodb-connection-string-url": "^3.0.1 || ^7.0.0" } } diff --git a/packages/mongodb-redact/src/index.spec.ts b/packages/mongodb-redact/src/index.spec.ts index ca48dbdd..02566a5f 100644 --- a/packages/mongodb-redact/src/index.spec.ts +++ b/packages/mongodb-redact/src/index.spec.ts @@ -161,13 +161,82 @@ describe('mongodb-redact', function () { expect(res).to.equal(''); }); - it('should redact MongoDB connection URIs', function () { - let res = redact( - 'mongodb://db1.example.net,db2.example.net:2500/?replicaSet=test&connectTimeoutMS=300000', - ); - expect(res).to.equal(''); - res = redact('mongodb://localhost,localhost:27018,localhost:27019'); - expect(res).to.equal(''); + describe('MongoDB connection strings', function () { + it('should redact MongoDB connection URIs', function () { + let res = redact( + 'mongodb://db1.example.net,db2.example.net:2500/?replicaSet=test&connectTimeoutMS=300000', + ); + expect(res).to.equal(''); + res = redact('mongodb://localhost,localhost:27018,localhost:27019'); + expect(res).to.equal(''); + }); + + it('should redact MongoDB URIs with credentials', function () { + let res = redact('mongodb://user:password@localhost:27017/admin'); + expect(res).to.equal(''); + res = redact('mongodb://admin:secret123@db.example.com/mydb'); + expect(res).to.equal(''); + }); + + it('should redact MongoDB URIs with special characters in usernames and passwords', function () { + let res = redact('mongodb://user:p%40ss!word@localhost:27017/'); + expect(res).to.equal(''); + res = redact('mongodb://ad!min:te%st#123$@db.example.com:27017/'); + expect(res).to.equal(''); + res = redact('mongodb://!user:my%20pass@localhost/mydb'); + expect(res).to.equal(''); + res = redact( + 'mongodb://user:p&ssw!rd#123@host.com:27017/db?authSource=admin', + ); + expect(res).to.equal(''); + }); + + it('should redact MongoDB SRV URIs', function () { + let res = redact( + 'mongodb+srv://user:password@cluster0.example.com/test', + ); + expect(res).to.equal(''); + res = redact( + 'mongodb+srv://admin:secret@mycluster.mongodb.net/mydb?retryWrites=true', + ); + expect(res).to.equal(''); + }); + + it('should redact MongoDB URIs with query parameters', function () { + let res = redact( + 'mongodb://localhost:27017/mydb?ssl=true&replicaSet=rs0', + ); + expect(res).to.equal(''); + res = redact( + 'mongodb://user:pass@host.com/db?authSource=admin&readPreference=primary', + ); + expect(res).to.equal(''); + }); + + it('should redact MongoDB URIs with replica sets', function () { + let res = redact( + 'mongodb://host1:27017,host2:27017,host3:27017/?replicaSet=myReplSet', + ); + expect(res).to.equal(''); + res = redact('mongodb://user:pass@host1,host2,host3/db?replicaSet=rs0'); + expect(res).to.equal(''); + }); + + it('should redact MongoDB URIs with IP addresses', function () { + let res = redact('mongodb://192.168.1.100:27017/mydb'); + expect(res).to.equal(''); + res = redact('mongodb://user:password@10.0.0.5:27017/admin'); + expect(res).to.equal(''); + }); + + it('should redact simple MongoDB URIs', function () { + let res = redact('mongodb://localhost'); + expect(res).to.equal(''); + res = redact('mongodb://localhost:27017'); + expect(res).to.equal(''); + res = redact('mongodb://localhost/mydb'); + expect(res).to.equal(''); + }); }); it('should redact general linux/unix user paths', function () { diff --git a/packages/mongodb-redact/src/index.ts b/packages/mongodb-redact/src/index.ts index 0754f7e8..d1a1171f 100644 --- a/packages/mongodb-redact/src/index.ts +++ b/packages/mongodb-redact/src/index.ts @@ -46,5 +46,8 @@ export function redact( return message; } +export { shouldRedactCommand } from './should-redact-command'; +export { redactConnectionString } from './redact-connection-string'; + export default redact; export type { Secret } from './secrets'; diff --git a/packages/mongodb-redact/src/redact-connection-string.spec.ts b/packages/mongodb-redact/src/redact-connection-string.spec.ts new file mode 100644 index 00000000..181e7592 --- /dev/null +++ b/packages/mongodb-redact/src/redact-connection-string.spec.ts @@ -0,0 +1,173 @@ +import { expect } from 'chai'; +import { redactConnectionString } from './redact-connection-string'; +import redact from '.'; + +describe('redactConnectionString', function () { + const testCases: Array<{ + description: string; + input: string; + expected: string; + }> = [ + { + description: 'should redact username and password', + input: 'mongodb://user:password@localhost:27017/admin', + expected: 'mongodb://@localhost:27017/admin', + }, + { + description: 'should redact only username when no password', + input: 'mongodb://user@localhost:27017/admin', + expected: 'mongodb://@localhost:27017/admin', + }, + { + description: 'should redact credentials in SRV URIs', + input: 'mongodb+srv://admin:sec!ret@cluster0.example.com/test', + expected: 'mongodb+srv://@cluster0.example.com/test', + }, + { + description: 'should redact passwords with ! character', + input: 'mongodb://user:p@ss!word@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: 'should redact passwords with # character', + input: 'mongodb://admin:test#123@db.example.com:27017/', + expected: 'mongodb://@db.example.com:27017/', + }, + { + description: 'should redact passwords with $ character', + input: 'mongodb://user:price$100@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: 'should redact passwords with % character', + input: 'mongodb://user:test%pass@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: 'should redact passwords with & character', + input: 'mongodb://user:rock&roll@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: 'should redact URL-encoded passwords', + input: 'mongodb://user:my%20password@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: + 'should redact complex passwords with multiple special characters', + input: 'mongodb://user:p&ssw!rd#123$@host.com:27017/db?authSource=admin', + expected: 'mongodb://@host.com:27017/db?authSource=admin', + }, + { + description: 'should redact usernames with special characters', + input: 'mongodb://us!er:password@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: 'should return URI unchanged when no credentials', + input: 'mongodb://localhost:27017/admin', + expected: 'mongodb://localhost:27017/admin', + }, + { + description: 'should handle simple localhost URI', + input: 'mongodb://localhost', + expected: 'mongodb://localhost/', + }, + { + description: 'should handle URI with database', + input: 'mongodb://localhost/mydb', + expected: 'mongodb://localhost/mydb', + }, + { + description: 'should handle URI with query parameters', + input: 'mongodb://localhost:27017/mydb?ssl=true&replicaSet=rs0', + expected: 'mongodb://localhost:27017/mydb?ssl=true&replicaSet=rs0', + }, + // URIs with replica sets + { + description: 'should redact credentials in replica set URIs', + input: + 'mongodb://user:pass@host1:27017,host2:27017,host3:27017/db?replicaSet=rs0', + expected: + 'mongodb://@host1:27017,host2:27017,host3:27017/db?replicaSet=rs0', + }, + { + description: 'should handle replica set URIs without credentials', + input: + 'mongodb://host1:27017,host2:27017,host3:27017/?replicaSet=myReplSet', + expected: + 'mongodb://host1:27017,host2:27017,host3:27017/?replicaSet=myReplSet', + }, + // URIs with IP addresses + { + description: 'should redact credentials with IP address host', + input: 'mongodb://user:password@192.168.1.100:27017/mydb', + expected: 'mongodb://@192.168.1.100:27017/mydb', + }, + { + description: 'should handle IP address URIs without credentials', + input: 'mongodb://10.0.0.5:27017/admin', + expected: 'mongodb://10.0.0.5:27017/admin', + }, + // SRV URIs + { + description: 'should handle SRV URIs without credentials', + input: 'mongodb+srv://cluster0.example.com/test', + expected: 'mongodb+srv://cluster0.example.com/test', + }, + // URIs with query parameters + { + description: 'should redact credentials and preserve query parameters', + input: + 'mongodb://user:pass@host.com/db?authSource=admin&readPreference=primary', + expected: + 'mongodb://@host.com/db?authSource=admin&readPreference=primary', + }, + { + description: 'should handle URIs with SSL options', + input: + 'mongodb://admin:secret@localhost:27017/mydb?ssl=true&tlsAllowInvalidCertificates=true', + expected: + 'mongodb://@localhost:27017/mydb?ssl=true&tlsAllowInvalidCertificates=true', + }, + { + description: 'should redact credentials in SRV URIs with query params', + input: + 'mongodb+srv://admin:secret@mycluster.mongodb.net/mydb?retryWrites=true', + expected: + 'mongodb+srv://@mycluster.mongodb.net/mydb?retryWrites=true', + }, + // Edge cases + { + description: 'should handle empty password', + input: 'mongodb://user:@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: + 'should handle password with only special characters (URL-encoded)', + input: 'mongodb://user:%21%40%23%24%25@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + { + description: 'should handle very long passwords', + input: `mongodb://user:${'a'.repeat(100)}@localhost:27017/`, + expected: 'mongodb://@localhost:27017/', + }, + { + description: 'should handle international characters in password', + input: 'mongodb://user:пароль@localhost:27017/', + expected: 'mongodb://@localhost:27017/', + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, function () { + const result = redactConnectionString(input); + expect(result).to.equal(expected); + + expect(redact(input)).to.equal(''); + }); + }); +}); diff --git a/packages/mongodb-redact/src/redact-connection-string.ts b/packages/mongodb-redact/src/redact-connection-string.ts new file mode 100644 index 00000000..9912764c --- /dev/null +++ b/packages/mongodb-redact/src/redact-connection-string.ts @@ -0,0 +1,5 @@ +import { redactConnectionString as redactConnectionStringImpl } from 'mongodb-connection-string-url'; + +export function redactConnectionString(uri: string): string { + return redactConnectionStringImpl(uri); +} diff --git a/packages/mongodb-redact/src/regexes.ts b/packages/mongodb-redact/src/regexes.ts index c4abff94..3266c7a2 100644 --- a/packages/mongodb-redact/src/regexes.ts +++ b/packages/mongodb-redact/src/regexes.ts @@ -27,6 +27,9 @@ export const regexes = [ '$1$6', ], + // MongoDB connection strings + [/mongodb(?:\+srv)?:\/\/\S+/gim, ''], + // IP addresses [ /((1?[0-9][0-9]?|2[0-4][0-9]|25[0-5])\.){3}(1?[0-9][0-9]?|2[0-4][0-9]|25[0-5])/gm, @@ -39,12 +42,6 @@ export const regexes = [ '', ], - // MongoDB connection strings - [ - /(mongodb:\/\/)(www\.)?[-a-zA-Z0-9@:%._+~#=,]{2,256}(\.[a-z]{2,6})?\b([-a-zA-Z0-9@:%_+.~#?&/=]*)/gim, - '', - ], - // Compass Schema URL fragments [/#schema\/\w+\.\w+/, '#schema/'], ] as const; diff --git a/packages/mongodb-redact/src/secrets.spec.ts b/packages/mongodb-redact/src/secrets.spec.ts index 3af8b617..ed2ae16e 100644 --- a/packages/mongodb-redact/src/secrets.spec.ts +++ b/packages/mongodb-redact/src/secrets.spec.ts @@ -93,4 +93,55 @@ describe('dictionary-based secret redaction', function () { usr: '', }); }); + + describe('special characters in passwords', function () { + it('redacts passwords at start, end, or entire string', function () { + expect( + redact('!start is pwd', [{ value: '!start', kind: 'password' }]), + ).to.equal(' is pwd'); + + expect( + redact('pwd is end!', [{ value: 'end!', kind: 'password' }]), + ).to.equal('pwd is '); + + expect( + redact('The password is !@#$%', [{ value: '!@#$%', kind: 'password' }]), + ).to.equal('The password is '); + }); + + it('redacts a special-character only connection string', function () { + const secret = '!#!!'; + const content = 'Connection string: mongodb://!!!#:!#!!@localhost:27017/'; + + const redacted = redact(content, [{ value: secret, kind: 'password' }]); + + expect(redacted).to.equal('Connection string: '); + }); + + for (const { char, password } of [ + { char: '.', password: 'test.pass' }, + { char: '*', password: 'test*pass' }, + { char: '+', password: 'test+pass' }, + { char: '?', password: 'test?pass' }, + { char: '[', password: 'test[123]' }, + { char: '(', password: 'test(abc)' }, + { char: '|', password: 'test|pass' }, + { char: '\\', password: 'test\\pass' }, + { char: '^', password: '^test123' }, + { char: '$', password: 'test$123' }, + { char: '@', password: 'user@123' }, + { char: '#', password: 'pass#word' }, + { char: '%', password: 'test%20' }, + { char: '&', password: 'rock&roll' }, + { char: 'լավ', password: 'լավ' }, + ]) { + it(`redacts passwords with ${char}`, function () { + const content = `pwd: ${password} end`; + const redacted = redact(content, [ + { value: password, kind: 'password' }, + ]); + expect(redacted).to.equal('pwd: end'); + }); + } + }); }); diff --git a/packages/mongodb-redact/src/secrets.ts b/packages/mongodb-redact/src/secrets.ts index 4c0a519e..fe4aa6b8 100644 --- a/packages/mongodb-redact/src/secrets.ts +++ b/packages/mongodb-redact/src/secrets.ts @@ -32,7 +32,10 @@ export function redactSecretsOnString( ); } - const regex = new RegExp(`\\b${escape(value)}\\b`, 'g'); + // Escape the value for use in regex and use negative lookahead/lookbehind + // to match secrets not surrounded by word characters + const escapedValue = escape(value); + const regex = new RegExp(`(?`) as T; } diff --git a/packages/mongodb-redact/src/should-redact-command.spec.ts b/packages/mongodb-redact/src/should-redact-command.spec.ts new file mode 100644 index 00000000..ea849546 --- /dev/null +++ b/packages/mongodb-redact/src/should-redact-command.spec.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; +import { shouldRedactCommand } from '.'; + +describe('shouldRedactCommand', function () { + for (const command of [ + 'db.createUser({ user: "test" })', + 'db.auth("user", "pass")', + 'db.updateUser("user", { roles: [] })', + 'db.changeUserPassword("user", "newpass")', + 'db = connect("mongodb://localhost")', + 'new Mongo("mongodb://localhost")', + ]) { + it(`returns true for ${command}`, function () { + expect(shouldRedactCommand(command)).to.be.true; + }); + } + + for (const command of [ + 'db.collection.find()', + 'db.collection.find({authentication: true})', + 'db.getUsers()', + 'show dbs', + ]) { + it(`returns false for ${command}`, function () { + expect(shouldRedactCommand(command)).to.be.false; + }); + } +}); diff --git a/packages/mongodb-redact/src/should-redact-command.ts b/packages/mongodb-redact/src/should-redact-command.ts new file mode 100644 index 00000000..445b7c50 --- /dev/null +++ b/packages/mongodb-redact/src/should-redact-command.ts @@ -0,0 +1,27 @@ +/** + * Regex pattern for commands that contain sensitive information and should be + * completely removed from history rather than redacted. + * + * These commands typically involve authentication or connection strings with credentials. + */ +const HIDDEN_COMMANDS = String.raw`\b(createUser|auth|updateUser|changeUserPassword|connect|Mongo)\b`; + +/** + * Checks if a mongosh command should be redacted because it often contains sensitive information like credentials. + * + * @param input - The command string to check + * @returns true if the command should be hidden/redacted, false otherwise + * + * @example + * ```typescript + * shouldRedactCommand('db.createUser({user: "admin", pwd: "secret"})') + * // Returns: true + * + * shouldRedactCommand('db.getUsers()') + * // Returns: false + * ``` + */ +export function shouldRedactCommand(input: string): boolean { + const hiddenCommands = new RegExp(HIDDEN_COMMANDS, 'g'); + return hiddenCommands.test(input); +}