Skip to content

Commit fe46df1

Browse files
committed
feat: add keychain to redact known sensitive information from loggers
This sets up a Keychain, a holder of known secrets. When a new secret is created it should be registered in the keychain so loggers can redact them and avoid leaking secrets into our logs.
1 parent d471cdd commit fe46df1

File tree

12 files changed

+240
-27
lines changed

12 files changed

+240
-27
lines changed

src/common/config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import os from "os";
33
import argv from "yargs-parser";
44
import type { CliOptions, ConnectionInfo } from "@mongosh/arg-parser";
55
import { generateConnectionInfoFromCliArgs } from "@mongosh/arg-parser";
6+
import { Keychain } from "./keychain.js";
7+
import type { Secret } from "./keychain.js";
68

79
// From: https://github.com/mongodb-js/mongosh/blob/main/packages/cli-repl/src/arg-parser.ts
810
const OPTIONS = {
@@ -316,6 +318,29 @@ function commaSeparatedToArray<T extends string[]>(str: string | string[] | unde
316318
return str as T;
317319
}
318320

321+
export function registerKnownSecretsInRootKeychain(userConfig: UserConfig): void {
322+
const keychain = Keychain.root;
323+
324+
const maybeRegister = (value: string | undefined, kind: Secret["kind"]) => {
325+
if (value) {
326+
keychain.register(value, kind);
327+
}
328+
};
329+
330+
maybeRegister(userConfig.apiClientId, "user");
331+
maybeRegister(userConfig.apiClientSecret, "password");
332+
maybeRegister(userConfig.awsAccessKeyId, "password");
333+
maybeRegister(userConfig.awsIamSessionToken, "password");
334+
maybeRegister(userConfig.awsSecretAccessKey, "password");
335+
maybeRegister(userConfig.awsSessionToken, "password");
336+
maybeRegister(userConfig.password, "password");
337+
maybeRegister(userConfig.tlsCAFile, "url");
338+
maybeRegister(userConfig.tlsCRLFile, "url");
339+
maybeRegister(userConfig.tlsCertificateKeyFile, "url");
340+
maybeRegister(userConfig.tlsCertificateKeyFilePassword, "password");
341+
maybeRegister(userConfig.username, "user");
342+
}
343+
319344
export function setupUserConfig({
320345
cli,
321346
env,
@@ -369,6 +394,7 @@ export function setupUserConfig({
369394
}
370395
}
371396

397+
registerKnownSecretsInRootKeychain(userConfig);
372398
return userConfig;
373399
}
374400

src/common/keychain.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export type Secret = {
2+
value: string;
3+
kind: string;
4+
};
5+
6+
/**
7+
* This class holds the secrets of a single server. Ideally, we might want to have a keychain
8+
* per session, but right now the loggers are set up by server and are not aware of the concept
9+
* of session and this would require a bigger refactor.
10+
*
11+
* Whenever we identify or create a secret (for example, Atlas login, CLI arguments...) we
12+
* should register them in the root Keychain (`Keychain.root.register`) or preferably
13+
* on the session keychain if available `this.session.keychain`.
14+
**/
15+
export class Keychain {
16+
private secrets: Secret[];
17+
private static rootKeychain: Keychain = new Keychain();
18+
19+
private constructor() {
20+
this.secrets = [];
21+
}
22+
23+
static get root(): Keychain {
24+
return Keychain.rootKeychain;
25+
}
26+
27+
register(value: Secret["value"], kind: Secret["kind"]): void {
28+
this.secrets.push({ value, kind });
29+
}
30+
31+
clearAllSecrets(): void {
32+
this.secrets = [];
33+
}
34+
35+
get allSecrets(): Secret[] {
36+
return [...this.secrets];
37+
}
38+
}

src/common/logger.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import redact from "mongodb-redact";
55
import type { LoggingMessageNotification } from "@modelcontextprotocol/sdk/types.js";
66
import { EventEmitter } from "events";
77
import type { Server } from "../lib.js";
8+
import type { Keychain } from "./keychain.js";
89

910
export type LogLevel = LoggingMessageNotification["params"]["level"];
1011

@@ -83,6 +84,10 @@ type DefaultEventMap = [never];
8384
export abstract class LoggerBase<T extends EventMap<T> = DefaultEventMap> extends EventEmitter<T> {
8485
private readonly defaultUnredactedLogger: LoggerType = "mcp";
8586

87+
constructor(private readonly keychain: Keychain | undefined) {
88+
super();
89+
}
90+
8691
public log(level: LogLevel, payload: LogPayload): void {
8792
// If no explicit value is supplied for unredacted loggers, default to "mcp"
8893
const noRedaction = payload.noRedaction !== undefined ? payload.noRedaction : this.defaultUnredactedLogger;
@@ -121,7 +126,7 @@ export abstract class LoggerBase<T extends EventMap<T> = DefaultEventMap> extend
121126
return message;
122127
}
123128

124-
return redact(message);
129+
return redact(message, this.keychain?.allSecrets ?? []);
125130
}
126131

127132
public info(payload: LogPayload): void {
@@ -179,6 +184,10 @@ export abstract class LoggerBase<T extends EventMap<T> = DefaultEventMap> extend
179184
export class ConsoleLogger extends LoggerBase {
180185
protected readonly type: LoggerType = "console";
181186

187+
public constructor(keychain: Keychain) {
188+
super(keychain);
189+
}
190+
182191
protected logCore(level: LogLevel, payload: LogPayload): void {
183192
const { id, context, message } = payload;
184193
console.error(
@@ -200,8 +209,8 @@ export class DiskLogger extends LoggerBase<{ initialized: [] }> {
200209
private bufferedMessages: { level: LogLevel; payload: LogPayload }[] = [];
201210
private logWriter?: MongoLogWriter;
202211

203-
public constructor(logPath: string, onError: (error: Error) => void) {
204-
super();
212+
public constructor(logPath: string, onError: (error: Error) => void, keychain: Keychain) {
213+
super(keychain);
205214

206215
void this.initialize(logPath, onError);
207216
}
@@ -261,8 +270,11 @@ export class McpLogger extends LoggerBase {
261270
"emergency",
262271
] as const;
263272

264-
public constructor(private readonly server: Server) {
265-
super();
273+
public constructor(
274+
private readonly server: Server,
275+
keychain: Keychain
276+
) {
277+
super(keychain);
266278
}
267279

268280
protected readonly type: LoggerType = "mcp";
@@ -294,7 +306,9 @@ export class CompositeLogger extends LoggerBase {
294306
private readonly attributes: Record<string, string> = {};
295307

296308
constructor(...loggers: LoggerBase[]) {
297-
super();
309+
// composite logger does not redact, only the actual delegates do the work
310+
// so we don't need the Keychain here
311+
super(undefined);
298312

299313
this.loggers = loggers;
300314
}

src/common/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
1616
import { ErrorCodes, MongoDBError } from "./errors.js";
1717
import type { ExportsManager } from "./exportsManager.js";
18+
import { Keychain } from "./keychain.js";
1819

1920
export interface SessionOptions {
2021
apiBaseUrl: string;
@@ -23,6 +24,7 @@ export interface SessionOptions {
2324
logger: CompositeLogger;
2425
exportsManager: ExportsManager;
2526
connectionManager: ConnectionManager;
27+
keychain: Keychain;
2628
}
2729

2830
export type SessionEvents = {
@@ -37,6 +39,8 @@ export class Session extends EventEmitter<SessionEvents> {
3739
readonly exportsManager: ExportsManager;
3840
readonly connectionManager: ConnectionManager;
3941
readonly apiClient: ApiClient;
42+
readonly keychain: Keychain;
43+
4044
mcpClient?: {
4145
name?: string;
4246
version?: string;
@@ -52,9 +56,11 @@ export class Session extends EventEmitter<SessionEvents> {
5256
logger,
5357
connectionManager,
5458
exportsManager,
59+
keychain,
5560
}: SessionOptions) {
5661
super();
5762

63+
this.keychain = keychain;
5864
this.logger = logger;
5965
const credentials: ApiClientCredentials | undefined =
6066
apiClientId && apiClientSecret

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { packageInfo } from "./common/packageInfo.js";
4242
import { StdioRunner } from "./transports/stdio.js";
4343
import { StreamableHttpRunner } from "./transports/streamableHttp.js";
4444
import { systemCA } from "@mongodb-js/devtools-proxy-support";
45+
import { Keychain } from "./common/keychain.js";
4546

4647
async function main(): Promise<void> {
4748
systemCA().catch(() => undefined); // load system CA asynchronously as in mongosh
@@ -114,7 +115,7 @@ main().catch((error: unknown) => {
114115
// At this point, we may be in a very broken state, so we can't rely on the logger
115116
// being functional. Instead, create a brand new ConsoleLogger and log the error
116117
// to the console.
117-
const logger = new ConsoleLogger();
118+
const logger = new ConsoleLogger(Keychain.root);
118119
logger.emergency({
119120
id: LogId.serverStartFailure,
120121
context: "server",

src/tools/atlas/connect/connectCluster.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ export class ConnectClusterTool extends AtlasToolBase {
110110
cn.password = password;
111111
cn.searchParams.set("authSource", "admin");
112112

113+
this.session.keychain.register(username, "user");
114+
this.session.keychain.register(password, "password");
115+
113116
return { connectionString: cn.toString(), atlas: connectedAtlasCluster };
114117
}
115118

src/tools/atlas/create/createDBUser.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export class CreateDBUserTool extends AtlasToolBase {
7878
body: input,
7979
});
8080

81+
this.session.keychain.register(username, "user");
82+
if (password) {
83+
this.session.keychain.register(password, "password");
84+
}
85+
8186
return {
8287
content: [
8388
{

src/transports/base.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CompositeLogger, ConsoleLogger, DiskLogger, McpLogger } from "../common
99
import { ExportsManager } from "../common/exportsManager.js";
1010
import { DeviceId } from "../helpers/deviceId.js";
1111
import { type ConnectionManagerFactoryFn } from "../common/connectionManager.js";
12+
import { Keychain } from "../common/keychain.js";
1213

1314
export abstract class TransportRunnerBase {
1415
public logger: LoggerBase;
@@ -21,16 +22,20 @@ export abstract class TransportRunnerBase {
2122
) {
2223
const loggers: LoggerBase[] = [...additionalLoggers];
2324
if (this.userConfig.loggers.includes("stderr")) {
24-
loggers.push(new ConsoleLogger());
25+
loggers.push(new ConsoleLogger(Keychain.root));
2526
}
2627

2728
if (this.userConfig.loggers.includes("disk")) {
2829
loggers.push(
29-
new DiskLogger(this.userConfig.logPath, (err) => {
30-
// If the disk logger fails to initialize, we log the error to stderr and exit
31-
console.error("Error initializing disk logger:", err);
32-
process.exit(1);
33-
})
30+
new DiskLogger(
31+
this.userConfig.logPath,
32+
(err) => {
33+
// If the disk logger fails to initialize, we log the error to stderr and exit
34+
console.error("Error initializing disk logger:", err);
35+
process.exit(1);
36+
},
37+
Keychain.root
38+
)
3439
);
3540
}
3641

@@ -59,6 +64,7 @@ export abstract class TransportRunnerBase {
5964
logger,
6065
exportsManager,
6166
connectionManager,
67+
keychain: Keychain.root,
6268
});
6369

6470
const telemetry = Telemetry.create(session, this.userConfig, this.deviceId);
@@ -73,7 +79,7 @@ export abstract class TransportRunnerBase {
7379
// We need to create the MCP logger after the server is constructed
7480
// because it needs the server instance
7581
if (this.userConfig.loggers.includes("mcp")) {
76-
logger.addLogger(new McpLogger(result));
82+
logger.addLogger(new McpLogger(result, Keychain.root));
7783
}
7884

7985
return result;

tests/integration/tools/atlas/dbUsers.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ describeWithAtlas("db users", (integration) => {
7474
expect(elements[0]?.text).toContain("created successfully");
7575
expect(elements[0]?.text).toContain(userName);
7676
expect(elements[0]?.text).not.toContain("testpassword");
77+
expect(integration.mcpServer().session.keychain.allSecrets).to.deep.equal([
78+
{
79+
value: userName,
80+
kind: "user",
81+
},
82+
{
83+
value: "testpassword",
84+
kind: "password",
85+
},
86+
]);
7787
});
7888

7989
it("should create a database user with generated password", async () => {
@@ -83,6 +93,21 @@ describeWithAtlas("db users", (integration) => {
8393
expect(elements[0]?.text).toContain("created successfully");
8494
expect(elements[0]?.text).toContain(userName);
8595
expect(elements[0]?.text).toContain("with password: `");
96+
97+
const passwordStart = elements[0]?.text.lastIndexOf(":") ?? -1;
98+
const passwordEnd = elements[0]?.text.length ?? 1 - 1;
99+
100+
const password = elements[0]?.text.substring(passwordStart).substring(0, passwordEnd).trim();
101+
expect(integration.mcpServer().session.keychain.allSecrets).to.deep.equal([
102+
{
103+
value: userName,
104+
kind: "user",
105+
},
106+
{
107+
value: password,
108+
kind: "password",
109+
},
110+
]);
86111
});
87112

88113
it("should add current IP to access list when creating a database user", async () => {

tests/unit/common/config.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import { describe, it, expect, vi, beforeEach } from "vitest";
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
import type { UserConfig } from "../../../src/common/config.js";
3-
import { setupUserConfig, defaultUserConfig, warnAboutDeprecatedCliArgs } from "../../../src/common/config.js";
3+
import {
4+
setupUserConfig,
5+
defaultUserConfig,
6+
warnAboutDeprecatedCliArgs,
7+
registerKnownSecretsInRootKeychain,
8+
} from "../../../src/common/config.js";
49
import type { CliOptions } from "@mongosh/arg-parser";
10+
import { Keychain } from "../../../src/common/keychain.js";
11+
import type { Secret } from "../../../src/common/keychain.js";
512

613
describe("config", () => {
714
describe("env var parsing", () => {
@@ -641,4 +648,40 @@ describe("Deprecated CLI arguments", () => {
641648
});
642649
});
643650
}
651+
652+
describe("keychain management", () => {
653+
type TestCase = { readonly cliArg: keyof UserConfig; secretKind: Secret["kind"] };
654+
const testCases = [
655+
{ cliArg: "apiClientId", secretKind: "user" },
656+
{ cliArg: "apiClientSecret", secretKind: "password" },
657+
{ cliArg: "awsAccessKeyId", secretKind: "password" },
658+
{ cliArg: "awsIamSessionToken", secretKind: "password" },
659+
{ cliArg: "awsSecretAccessKey", secretKind: "password" },
660+
{ cliArg: "awsSessionToken", secretKind: "password" },
661+
{ cliArg: "password", secretKind: "password" },
662+
{ cliArg: "tlsCAFile", secretKind: "url" },
663+
{ cliArg: "tlsCRLFile", secretKind: "url" },
664+
{ cliArg: "tlsCertificateKeyFile", secretKind: "url" },
665+
{ cliArg: "tlsCertificateKeyFilePassword", secretKind: "password" },
666+
{ cliArg: "username", secretKind: "user" },
667+
] as TestCase[];
668+
let keychain: Keychain;
669+
670+
beforeEach(() => {
671+
keychain = Keychain.root;
672+
keychain.clearAllSecrets();
673+
});
674+
675+
afterEach(() => {
676+
keychain.clearAllSecrets();
677+
});
678+
679+
for (const { cliArg, secretKind } of testCases) {
680+
it(`should register ${cliArg} as a secret of kind ${secretKind} in the root keychain`, () => {
681+
registerKnownSecretsInRootKeychain({ [cliArg]: cliArg });
682+
683+
expect(keychain.allSecrets).to.deep.equal([{ value: cliArg, kind: secretKind }]);
684+
});
685+
}
686+
});
644687
});

0 commit comments

Comments
 (0)